From 2597cba2294918d661ab81f4f347cbf67c716d19 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 06:40:15 -0700 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20CoeditingRegistry=20opt-in=20gate?= =?UTF-8?q?=20(INV-10,=20spec=20=C2=A76.4)=20=E2=80=94=20enter/stop=20comm?= =?UTF-8?q?ands=20+=20context=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- package.json | 20 ++++++++++ src/coeditingRegistry.ts | 57 +++++++++++++++++++++++++++++ src/extension.ts | 29 +++++++++++++++ test/coeditingRegistry.test.ts | 63 ++++++++++++++++++++++++++++++++ test/e2e/suite/coediting.test.ts | 20 ++++++++++ 5 files changed, 189 insertions(+) create mode 100644 src/coeditingRegistry.ts create mode 100644 test/coeditingRegistry.test.ts create mode 100644 test/e2e/suite/coediting.test.ts diff --git a/package.json b/package.json index 913b6fa..698ab25 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,16 @@ "command": "cowriting.proposalRejectMenu", "title": "Reject Claude Proposal", "category": "Cowriting" + }, + { + "command": "cowriting.coeditDocument", + "title": "✦ Coedit this Document with Claude", + "category": "Cowriting" + }, + { + "command": "cowriting.stopCoediting", + "title": "Stop editing with Claude", + "category": "Cowriting" } ], "menus": { @@ -207,6 +217,16 @@ "command": "cowriting.createThread", "when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)", "group": "1_cowriting@2" + }, + { + "command": "cowriting.coeditDocument", + "when": "editorLangId == markdown && !cowriting.isCoediting", + "group": "1_cowriting@1" + }, + { + "command": "cowriting.stopCoediting", + "when": "editorLangId == markdown && cowriting.isCoediting", + "group": "1_cowriting@9" } ], "comments/commentThread/context": [ diff --git a/src/coeditingRegistry.ts b/src/coeditingRegistry.ts new file mode 100644 index 0000000..9177478 --- /dev/null +++ b/src/coeditingRegistry.ts @@ -0,0 +1,57 @@ +/** + * 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; + 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(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(); + } +} diff --git a/src/extension.ts b/src/extension.ts index 3a81496..272ae45 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -14,6 +14,7 @@ import { TrackChangesPreviewController } from "./trackChangesPreview"; import { LiveProgressUi } from "./liveProgressUi"; import { EditorProposalController } from "./editorProposalController"; import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath"; +import { CoeditingRegistry } from "./coeditingRegistry"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; @@ -27,6 +28,7 @@ export interface CowritingApi { sidecarRouter: SidecarRouter; liveProgressUi: LiveProgressUi; editorProposalController: EditorProposalController; + coeditingRegistry: CoeditingRegistry; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { @@ -59,6 +61,32 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef }), ); + // --- PUC-7 (native-surfaces migration): the INV-10 opt-in gate --- + // Every later native surface (SCM/QuickDiff, comments, preview annotations, + // edit commands, decorations) checks isCoediting before attaching. Persisted + // in workspaceState so a reload restores the set. + 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); + // --- F6: baseline data layer (Features #17 + #19) — workspace-INDEPENDENT --- // The two-pane vscode.diff VIEW was removed in #34 (F10's rendered preview is // the single review surface); only the baseline store survives, consumed by @@ -362,6 +390,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef sidecarRouter, liveProgressUi, editorProposalController, + coeditingRegistry, }; } diff --git a/test/coeditingRegistry.test.ts b/test/coeditingRegistry.test.ts new file mode 100644 index 0000000..05ff076 --- /dev/null +++ b/test/coeditingRegistry.test.ts @@ -0,0 +1,63 @@ +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 } & { get: any; update: any } { + const store = new Map(); + 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); + }); +}); diff --git a/test/e2e/suite/coediting.test.ts b/test/e2e/suite/coediting.test.ts new file mode 100644 index 0000000..cd95bbc --- /dev/null +++ b/test/e2e/suite/coediting.test.ts @@ -0,0 +1,20 @@ +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); + }); +}); -- 2.39.5 From de837577546082a30b2d3cd57311bf2456b85768 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 06:45:59 -0700 Subject: [PATCH 02/18] test: cover syncContext context-key derivation + exit idempotency (CoeditingRegistry) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements comprehensive unit test coverage for CoeditingRegistry.syncContext, which derives context keys (cowriting.isCoediting, cowriting.baselineMode) via vscode.commands.executeCommand — previously untested. Added 8 new tests: - isCoediting: true for active editor with coediting doc - isCoediting: false for undefined editor or non-entered doc - baselineMode: uses hook result when set; defaults to "" otherwise - exit idempotency: double exit fires onDidChange only once All assertions inspect the mocked executeCommand call arguments directly. Co-Authored-By: Claude Fable 5 --- test/coeditingRegistry.test.ts | 106 +++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/test/coeditingRegistry.test.ts b/test/coeditingRegistry.test.ts index 05ff076..915cfc5 100644 --- a/test/coeditingRegistry.test.ts +++ b/test/coeditingRegistry.test.ts @@ -14,6 +14,7 @@ vi.mock("vscode", () => ({ commands: { executeCommand: vi.fn() }, window: { activeTextEditor: undefined }, })); +import * as vscode from "vscode"; import { CoeditingRegistry } from "../src/coeditingRegistry"; function memento(): { store: Map } & { get: any; update: any } { @@ -60,4 +61,109 @@ describe("CoeditingRegistry", () => { reg.enter(uri); expect(events).toHaveLength(1); }); + + it("exit is idempotent (no duplicate events on double exit)", () => { + 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.exit(uri); + reg.exit(uri); + expect(events).toHaveLength(2); // enter, then exit (second exit fires nothing) + }); + + it("syncContext: active editor with coediting doc sets isCoediting=true", () => { + const reg = new CoeditingRegistry(memento() as any); + const uri = { toString: () => "file:///a.md" } as any; + reg.enter(uri); + + const mockExecuteCommand = vscode.commands.executeCommand as any; + mockExecuteCommand.mockClear(); + + const editor = { document: { uri } } as any; + reg.syncContext(editor); + + expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.isCoediting", true); + }); + + it("syncContext: undefined editor sets isCoediting=false", () => { + const reg = new CoeditingRegistry(memento() as any); + const mockExecuteCommand = vscode.commands.executeCommand as any; + mockExecuteCommand.mockClear(); + + reg.syncContext(undefined); + + expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.isCoediting", false); + }); + + it("syncContext: non-coediting doc sets isCoediting=false", () => { + const reg = new CoeditingRegistry(memento() as any); + const mockExecuteCommand = vscode.commands.executeCommand as any; + mockExecuteCommand.mockClear(); + + const uri = { toString: () => "file:///nonexistent.md" } as any; + const editor = { document: { uri } } as any; + reg.syncContext(editor); + + expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.isCoediting", false); + }); + + it("syncContext: baselineMode defaults to empty string when not coediting", () => { + const reg = new CoeditingRegistry(memento() as any); + const mockExecuteCommand = vscode.commands.executeCommand as any; + mockExecuteCommand.mockClear(); + + const uri = { toString: () => "file:///a.md" } as any; + const editor = { document: { uri } } as any; + reg.syncContext(editor); + + expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.baselineMode", ""); + }); + + it("syncContext: baselineMode uses hook result when coediting and hook is set", () => { + const reg = new CoeditingRegistry(memento() as any); + const uri = { toString: () => "file:///a.md" } as any; + reg.enter(uri); + + reg.baselineModeOf = vi.fn((): "head" | "snapshot" | undefined => "snapshot"); + + const mockExecuteCommand = vscode.commands.executeCommand as any; + mockExecuteCommand.mockClear(); + + const editor = { document: { uri } } as any; + reg.syncContext(editor); + + expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.baselineMode", "snapshot"); + }); + + it("syncContext: baselineMode defaults to empty string when hook returns undefined", () => { + const reg = new CoeditingRegistry(memento() as any); + const uri = { toString: () => "file:///a.md" } as any; + reg.enter(uri); + + reg.baselineModeOf = vi.fn((): "head" | "snapshot" | undefined => undefined); + + const mockExecuteCommand = vscode.commands.executeCommand as any; + mockExecuteCommand.mockClear(); + + const editor = { document: { uri } } as any; + reg.syncContext(editor); + + expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.baselineMode", ""); + }); + + it("syncContext: calls executeCommand twice (isCoediting and baselineMode)", () => { + const reg = new CoeditingRegistry(memento() as any); + const uri = { toString: () => "file:///a.md" } as any; + reg.enter(uri); + + const mockExecuteCommand = vscode.commands.executeCommand as any; + mockExecuteCommand.mockClear(); + + const editor = { document: { uri } } as any; + reg.syncContext(editor); + + expect(mockExecuteCommand).toHaveBeenCalledTimes(2); + }); }); -- 2.39.5 From 447a1170ce857078b921af44eac0f5ddb96efd3c Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 07:09:55 -0700 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20baseline=20router=20=E2=80=94=20g?= =?UTF-8?q?it=20HEAD=20+=20snapshot=20per=20INV-7/D13/D14;=20retire=20mach?= =?UTF-8?q?ine-landing=20advance=20(spec=20=C2=A76.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 of the native-surfaces migration plan. GitBaselineAdapter resolves a document's HEAD blob via the built-in vscode.git extension (hardened with a .git/logs/HEAD watch that nudges repo.status(), since the git extension's own watcher doesn't reliably notice out-of-band commits on non-workspace-folder repos in the E2E host). DiffViewController is reworked into a baseline router: head mode (git-tracked, never persisted, re-read on commit) vs snapshot mode (captured on CoeditingRegistry entry, re-pinned by "Mark Changes as Reviewed" — renamed from cowriting.pinDiffBaseline, D14). The shipped machine-landing baseline advance (#48/INV-18) is retired: a landed Claude edit now stays a visible change-since-baseline until commit or review (INV-7/D21). Legacy on-disk reasons ("opened"/"machine-landing") migrate to "entered" via BaselineStore.normalizeReason on load. Co-Authored-By: Claude Fable 5 --- package.json | 6 +- src/attributionController.ts | 20 ++- src/baselineStore.ts | 19 +- src/diffViewController.ts | 169 +++++++++++++----- src/editorProposalController.ts | 2 +- src/extension.ts | 35 ++-- src/gitBaseline.ts | 131 ++++++++++++++ src/proposalController.ts | 9 +- src/trackChangesModel.ts | 13 +- src/trackChangesPreview.ts | 10 +- test/baselineStore.test.ts | 42 ++++- .../suite-no-workspace/noWorkspace.test.ts | 15 +- test/e2e/suite/authorship.test.ts | 14 +- test/e2e/suite/baselineRouter.test.ts | 48 +++++ test/e2e/suite/diffView.test.ts | 66 ++++--- test/e2e/suite/f10Review.test.ts | 20 ++- test/e2e/suite/f11Toolbar.test.ts | 12 +- test/e2e/suite/f12InlineDiff.test.ts | 3 + test/e2e/suite/helpers.ts | 32 ++++ test/e2e/suite/s48PinClean.test.ts | 3 + test/e2e/suite/trackChangesPreview.test.ts | 21 ++- test/e2e/suite/undoMarks.test.ts | 4 + test/trackChangesModel.test.ts | 4 +- 23 files changed, 558 insertions(+), 140 deletions(-) create mode 100644 src/gitBaseline.ts create mode 100644 test/e2e/suite/baselineRouter.test.ts create mode 100644 test/e2e/suite/helpers.ts diff --git a/package.json b/package.json index 698ab25..eb44367 100644 --- a/package.json +++ b/package.json @@ -85,8 +85,8 @@ "category": "Cowriting" }, { - "command": "cowriting.pinDiffBaseline", - "title": "Cowriting: Pin Review Baseline to Now", + "command": "cowriting.markReviewed", + "title": "Mark Changes as Reviewed", "category": "Cowriting" }, { @@ -149,7 +149,7 @@ "when": "false" }, { - "command": "cowriting.pinDiffBaseline", + "command": "cowriting.markReviewed", "when": "editorLangId == markdown" }, { diff --git a/src/attributionController.ts b/src/attributionController.ts index c3e7066..94f759f 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -56,8 +56,11 @@ export class AttributionController implements vscode.Disposable { /** * F6 (§6.2/§6.4): the single machine-landing signal. Fired after a real * (non-no-op) seam apply succeeds. INV-9 makes the seam the sole machine-edit - * ingress, so this is the sole signal — DiffViewController subscribes to - * advance the baseline; no call-site wiring, no future driver can forget it. + * ingress, so this is the sole signal. Native-surfaces migration (INV-7/ + * INV-18 retirement, spec §6.4): DiffViewController no longer subscribes + * here to advance the baseline — a landed edit stays a visible + * change-since-baseline until commit (head mode) or "Mark Changes as + * Reviewed" (snapshot mode). Other consumers may still subscribe. */ private readonly applyEmitter = new vscode.EventEmitter<{ document: vscode.TextDocument }>(); readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event; @@ -291,9 +294,10 @@ export class AttributionController implements vscode.Disposable { ); } if (ok && opts?.landBaseline !== false) { - // F6 (INV-18): a real machine landing — advance the baseline. F12 (INV-48) - // suppresses this for optimistic apply: the proposed text is in the buffer - // but the change stays PENDING (baseline at pre-proposal) until accept. + // F6 (INV-18, retired as a baseline advance — spec §6.4/INV-7): fire the + // machine-landing signal. F12 (INV-48) suppresses this for optimistic + // apply: the proposed text is in the buffer but the change stays PENDING + // until accept. this.applyEmitter.fire({ document }); } return ok; @@ -302,8 +306,10 @@ export class AttributionController implements vscode.Disposable { /** * F12/#64 (INV-51): fire the machine-landing signal WITHOUT applying text — used * by finalize-in-place, where the proposed text already landed in the buffer via - * optimistic apply (`landBaseline:false`) and accept only needs to advance the - * F6 baseline so the now-accepted change stops reading as pending. + * optimistic apply (`landBaseline:false`) and accept marks the landing so the + * now-accepted change is recorded (INV-7: the baseline itself no longer + * advances on landing — it advances only on commit / "Mark Changes as + * Reviewed"). */ signalLanded(document: vscode.TextDocument): void { if (this.isTracked(document)) this.applyEmitter.fire({ document }); diff --git a/src/baselineStore.ts b/src/baselineStore.ts index 4980aff..b2c1a79 100644 --- a/src/baselineStore.ts +++ b/src/baselineStore.ts @@ -7,11 +7,19 @@ * key is a hash of the document URI (the controller derives it), not a * workspace-relative path; an untitled buffer has no durable identity and is * never persisted here (in-memory only — see DiffViewController). + * + * INV-7 (native-surfaces migration, spec §6.4): the baseline is now either the + * git-HEAD blob (never persisted — see GitBaselineAdapter/DiffViewController) + * or a snapshot captured on coediting-entry / re-pinned by "Mark Changes as + * Reviewed". `"opened"`/`"machine-landing"` are legacy reasons from the + * shipped #17/#48 baseline; `normalizeReason` migrates them on load so old + * on-disk baselines keep round-tripping under the new vocabulary. */ import * as fs from "node:fs"; import * as path from "node:path"; -export type BaselineReason = "opened" | "machine-landing" | "pinned"; +export type BaselineReason = "entered" | "pinned" | "head"; +type LegacyBaselineReason = BaselineReason | "opened" | "machine-landing"; export interface Baseline { /** the document URI (the identity the key is derived from) — for round-trip/debug. */ @@ -22,6 +30,12 @@ export interface Baseline { reason: BaselineReason; } +/** Migrate legacy on-disk reasons to the current vocabulary (§6.4 INV-7). */ +export function normalizeReason(reason: LegacyBaselineReason): BaselineReason { + if (reason === "opened" || reason === "machine-landing") return "entered"; + return reason; +} + export class BaselineStore { /** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */ constructor(private readonly storageDir: string) {} @@ -34,7 +48,8 @@ export class BaselineStore { load(key: string): Baseline | null { const p = this.baselinePath(key); if (!fs.existsSync(p)) return null; - return JSON.parse(fs.readFileSync(p, "utf8")) as Baseline; + const raw = JSON.parse(fs.readFileSync(p, "utf8")) as Baseline; + return { ...raw, reason: normalizeReason(raw.reason) }; } /** Newest epoch wins — overwrite in place, no history (state-not-history, the F3/F4 precedent). */ diff --git a/src/diffViewController.ts b/src/diffViewController.ts index ef649a2..2a7f2fd 100644 --- a/src/diffViewController.ts +++ b/src/diffViewController.ts @@ -1,9 +1,16 @@ /** - * DiffViewController — F6 baseline data layer (spec §6.4/§6.7). Owns the baseline - * lifecycle (initialize at first sight / advance at every machine landing / pin - * on demand) and serves it to F7/F10 via `getBaseline` + the additive - * `onDidChangeBaseline` event. A pure data layer: never mutates the document, - * sidecar, or attribution state (INV-19). + * DiffViewController — the baseline router (native-surfaces migration, spec + * §6.4, INV-7). Resolves each coedited document's review baseline to one of two + * modes: **head** (git-tracked — the baseline is the `HEAD` blob, re-read on + * every commit, never persisted) or **snapshot** (untracked/no-repo — captured + * on coediting-entry, re-pinned on demand by "Mark Changes as Reviewed"). + * Baselines are established ONLY for documents the CoeditingRegistry has + * entered (INV-10) — opening a file no longer captures one (the retired + * `ensureBaseline`/#17 behavior). The shipped machine-landing advance (#48, + * INV-18) is retired too: a landed Claude edit stays a visible change-since- + * baseline until the file is committed (head mode) or reviewed (snapshot + * mode) — spec INV-7's note, D21. A pure data layer: never mutates the + * document, sidecar, or attribution state (INV-19). * * The F6 two-pane `vscode.diff` *view* (toggle UI + the `cowriting-baseline:` * virtual document) was removed in #34 once F10 made the rendered preview the @@ -12,37 +19,57 @@ * The baseline works on ANY text document, not just workspace files — it needs no * `.threads/` sidecar, only a stable doc identity + a storage home. So it has * its OWN diffable predicate (any `file:` or `untitled:` doc), decoupled from - * F2's workspace `isTracked`. Persistable docs (`file:`) keep their baseline in - * VS Code's per-extension GLOBAL storage keyed by a hash of the document URI; - * untitled buffers have no durable identity, so their baseline is in-memory - * only (lost on reload) — the same degrade the storage-unavailable path uses. + * F2's workspace `isTracked`. Persistable docs (`file:`) keep their SNAPSHOT + * baseline in VS Code's per-extension GLOBAL storage keyed by a hash of the + * document URI (INV-7: head-mode baselines are storage-free — never + * persisted); untitled buffers have no durable identity, so their baseline is + * in-memory only (lost on reload) — the same degrade the storage-unavailable + * path uses. */ import { createHash } from "node:crypto"; import * as vscode from "vscode"; import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore"; +import type { GitBaselineAdapter } from "./gitBaseline"; +import type { CoeditingRegistry } from "./coeditingRegistry"; export class DiffViewController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; /** Source of truth for the baseline, keyed by `document.uri.toString()`. */ private readonly baselines = new Map(); - /** F7 (additive): fires on every baseline capture (open / advance / pin) so - * the track-changes preview refreshes without polling. Carries the real - * document URI. */ + /** Which mode each established document resolved to (INV-7). */ + private readonly modes = new Map(); + /** F7 (additive): fires on every baseline capture (establish / head-refresh / + * pin) so the track-changes preview refreshes without polling. Carries the + * real document URI. */ private readonly onDidChangeBaselineEmitter = new vscode.EventEmitter<{ uri: string }>(); readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event; private storageWarned = false; - constructor(private readonly store: BaselineStore | null) { + constructor( + private readonly store: BaselineStore | null, + private readonly git: GitBaselineAdapter, + registry: CoeditingRegistry, + ) { this.disposables.push( this.onDidChangeBaselineEmitter, - vscode.commands.registerCommand("cowriting.pinDiffBaseline", () => + vscode.commands.registerCommand("cowriting.markReviewed", () => this.pinCommand(vscode.window.activeTextEditor), ), - // F6 captures a baseline for any diffable doc the moment it is first seen, - // independent of the workspace gate (so "opened" is the open-time text). - vscode.workspace.onDidOpenTextDocument((d) => this.ensureBaseline(d)), + // INV-10: a baseline is established only once its document ENTERS + // coediting; exiting keeps the stored baseline as-is (PUC-7). + registry.onDidChange(({ uri, coediting }) => { + if (!coediting) return; + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); + if (doc) void this.establish(doc); + }), + // A commit on a watched repo may advance a head-mode baseline (D13). + git.onDidChangeHead(() => void this.refreshHeadBaselines()), ); - for (const d of vscode.workspace.textDocuments) this.ensureBaseline(d); + // Restore already-coediting documents (e.g. after an extension host reload). + for (const key of registry.list()) { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); + if (doc) void this.establish(doc); + } } // ---- diffability / identity -------------------------------------------------------- @@ -51,7 +78,7 @@ export class DiffViewController implements vscode.Disposable { private isDiffable(document: vscode.TextDocument): boolean { return document.uri.scheme === "file" || document.uri.scheme === "untitled"; } - /** Only `file:` docs have a durable identity to persist a baseline against. */ + /** Only `file:` docs have a durable identity to persist a SNAPSHOT baseline against. */ private isPersistable(document: vscode.TextDocument): boolean { return document.uri.scheme === "file"; } @@ -64,36 +91,66 @@ export class DiffViewController implements vscode.Disposable { return createHash("sha256").update(uriKey).digest("hex"); } - // ---- baseline lifecycle (§6.4) ----------------------------------------------------- + // ---- baseline lifecycle (§6.4, INV-7) ----------------------------------------------- - /** First sight of a diffable doc: load the stored baseline, else capture `opened`. */ - ensureBaseline(document: vscode.TextDocument): void { + /** + * Resolve this document's baseline mode and (re)establish its baseline. + * git-tracked (a HEAD blob resolves) → **head** mode, baseline = HEAD, never + * persisted. Otherwise → **snapshot** mode: load a previously-stored + * snapshot, else capture the buffer now (`"entered"`). Called once when a + * document enters coediting (registry.enter) and at startup for documents + * already coediting. + */ + async establish(document: vscode.TextDocument): Promise { if (!this.isDiffable(document)) return; const key = this.uriKey(document); - if (this.baselines.has(key)) return; + 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)) { - try { - const stored = this.store.load(this.storageKey(key)); - if (stored) { - this.baselines.set(key, stored); - return; - } - } catch { - this.warnStorageOnce(); + const stored = this.tryLoad(key); // existing load path, legacy reasons normalized + if (stored) { + this.baselines.set(key, stored); + this.fire(key); + return; } } - this.capture(document, "opened"); + this.capture(document, "entered"); } - /** Machine landing (INV-18): re-capture so landed text never shows as a change. */ - advance(document: vscode.TextDocument): void { - if (!this.isDiffable(document)) return; - this.capture(document, "machine-landing"); + /** The mode a document's baseline resolved to, or undefined before `establish`. */ + modeOf(uriString: string): "head" | "snapshot" | undefined { + return this.modes.get(uriString); } - /** Human pin: baseline := now; the preview's change-marks empty (left = right). */ + /** Re-read HEAD for every head-mode document; a commit advances the baseline (D13). */ + private async refreshHeadBaselines(): Promise { + 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) + } + } + } + + /** Human pin ("Mark Changes as Reviewed", D14): snapshot mode only — a + * git-tracked (head-mode) baseline advances only by commit. */ pin(document: vscode.TextDocument): void { if (!this.isDiffable(document)) return; + const key = this.uriKey(document); + if (this.modes.get(key) !== "snapshot") { + void vscode.window.showWarningMessage( + "Cowriting: this document is git-tracked — commit to advance the baseline.", + ); + return; + } this.capture(document, "pinned"); } @@ -103,12 +160,38 @@ export class DiffViewController implements vscode.Disposable { const baseline: Baseline = { uri: key, text: document.getText(), capturedAt: new Date().toISOString(), reason }; this.baselines.set(key, baseline); if (this.store && this.isPersistable(document)) { - try { - this.store.save(this.storageKey(key), baseline); - } catch { - this.warnStorageOnce(); - } + this.trySave(key, baseline); } + this.fire(key); + } + + /** Set a baseline from resolved text directly (head mode — no document buffer read). */ + 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.fire(key); + } + + private tryLoad(key: string): Baseline | null { + try { + return this.store!.load(this.storageKey(key)); + } catch { + this.warnStorageOnce(); + return null; + } + } + + private trySave(key: string, baseline: Baseline): void { + try { + this.store!.save(this.storageKey(key), baseline); + } catch { + this.warnStorageOnce(); + } + } + + private fire(key: string): void { this.onDidChangeBaselineEmitter.fire({ uri: key }); } @@ -122,7 +205,7 @@ export class DiffViewController implements vscode.Disposable { private pinCommand(editor: vscode.TextEditor | undefined): void { if (!editor || !this.isDiffable(editor.document)) { - void vscode.window.showWarningMessage("Cowriting: focus a text editor to pin its review baseline."); + void vscode.window.showWarningMessage("Cowriting: focus a text editor to mark its changes as reviewed."); return; } this.pin(editor.document); diff --git a/src/editorProposalController.ts b/src/editorProposalController.ts index 691af60..370b2c5 100644 --- a/src/editorProposalController.ts +++ b/src/editorProposalController.ts @@ -57,7 +57,7 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL vscode.languages.registerCodeLensProvider({ language: "markdown" }, this), this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)), vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)), - // Refresh committed decorations when the baseline advances (pin / machine-landing / open). + // Refresh committed decorations when the baseline changes (establish / pin / head-refresh). this.diffView.onDidChangeBaseline(({ uri }) => { const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri); if (ed) this.renderEditor(ed); diff --git a/src/extension.ts b/src/extension.ts index 272ae45..410a504 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,6 +10,7 @@ import { BaselineStore } from "./baselineStore"; import { GlobalSidecarStore } from "./globalSidecarStore"; import { SidecarRouter } from "./sidecarRouter"; import { DiffViewController } from "./diffViewController"; +import { GitBaselineAdapter } from "./gitBaseline"; import { TrackChangesPreviewController } from "./trackChangesPreview"; import { LiveProgressUi } from "./liveProgressUi"; import { EditorProposalController } from "./editorProposalController"; @@ -87,17 +88,21 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ); coeditingRegistry.syncContext(vscode.window.activeTextEditor); - // --- F6: baseline data layer (Features #17 + #19) — workspace-INDEPENDENT --- - // The two-pane vscode.diff VIEW was removed in #34 (F10's rendered preview is - // the single review surface); only the baseline store survives, consumed by - // F7/F10. It tracks a baseline for ANY diffable doc (file or untitled), so it - // is constructed regardless of whether a folder is open. Baseline lives in VS - // Code's per-extension GLOBAL storage (always present), never the repo - // (INV-19). The machine-landing advance is wired below. The controller - // self-wires baseline capture on open. + // --- Baseline router (native-surfaces migration, spec §6.4, INV-7) — workspace- + // INDEPENDENT --- The two-pane vscode.diff VIEW was removed in #34 (F10's + // rendered preview is the single review surface); only the baseline data + // layer survives, consumed by F7/F10. It tracks a baseline for ANY diffable + // doc (file or untitled), so it is constructed regardless of whether a folder + // is open. A SNAPSHOT baseline lives in VS Code's per-extension GLOBAL + // storage (always present), never the repo (INV-19); a git-tracked doc's HEAD + // baseline is never persisted (INV-7). Baselines are established only for + // documents the CoeditingRegistry has entered — see the registry wiring + // inside DiffViewController's constructor. + const gitBaseline = new GitBaselineAdapter(); + context.subscriptions.push(gitBaseline); const baselineStorageDir = context.globalStorageUri?.fsPath; const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null; - const diffViewController = new DiffViewController(baselineStore); + const diffViewController = new DiffViewController(baselineStore, gitBaseline, coeditingRegistry); context.subscriptions.push(diffViewController); // F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage @@ -176,12 +181,12 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef }), ); - // --- F6 machine-landing wiring — now for ANY authorable doc --- - // The seam's single machine-landing signal advances the F6 baseline (INV-18); - // the seam can now fire on out-of-folder files too, so wire it unconditionally. - context.subscriptions.push( - attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)), - ); + // NOTE (native-surfaces migration, INV-7/INV-18 retirement): the shipped + // machine-landing baseline advance (#48) — wiring + // attributionController.onDidApplyAgentEdit → diffViewController.advance — + // was removed here. A landed Claude edit now stays a visible change-since- + // baseline until the file is committed (head mode) or reviewed via + // "Mark Changes as Reviewed" (snapshot mode). // One SHARED sidecar watcher for both controllers; self-writes are suppressed // centrally in the repo store (only repo `.threads/` sidecars are watched — diff --git a/src/gitBaseline.ts b/src/gitBaseline.ts new file mode 100644 index 0000000..3997b3b --- /dev/null +++ b/src/gitBaseline.ts @@ -0,0 +1,131 @@ +/** + * 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(). + * + * Hardening (Task 2 E2E, host-verified): for a repo that is NOT a workspace + * folder (the common case here — F8/#19 precedent, coediting works on any + * file), the git extension's own filesystem watcher does not reliably notice + * an out-of-band commit (e.g. a `git commit` run from a terminal, or in the + * host E2E) — `repo.state.onDidChange` can simply never fire. We additionally + * watch `.git/logs/HEAD` (the reflog VS Code itself also watches for + * in-workspace repos) with Node's own `fs.watch` and nudge `repo.status()`, + * which reliably re-syncs `state.HEAD` and lets the existing onDidChange path + * do the rest. Best-effort: any failure here just falls back to the passive + * listener. + */ +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 }; + show(ref: string, path: string): Promise; + status(): Promise; +} +interface GitAPI { + repositories: GitRepository[]; + getRepository(uri: vscode.Uri): GitRepository | null; + openRepository(root: vscode.Uri): Promise; + onDidOpenRepository: vscode.Event; +} + +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(); + private readonly lastCommit = new Map(); // repo root → HEAD commit + private readonly emitter = new vscode.EventEmitter(); + readonly onDidChangeHead = this.emitter.event; + private readonly disposables: vscode.Disposable[] = [this.emitter]; + + private async gitApi(): Promise { + 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 { + 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 { + 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(); + } + }), + ); + this.watchReflog(repo); + } + + /** Best-effort nudge: watch `.git/logs/HEAD` directly and force `repo.status()` + * on any write, so an out-of-band commit is picked up even when the git + * extension's own watcher doesn't fire (see the class doc). */ + private watchReflog(repo: GitRepository): void { + try { + const reflog = path.join(repo.rootUri.fsPath, ".git", "logs", "HEAD"); + if (!fs.existsSync(reflog)) return; + let pending: ReturnType | undefined; + const watcher = fs.watch(reflog, () => { + if (pending) clearTimeout(pending); + pending = setTimeout(() => void repo.status().catch(() => {}), 150); + }); + this.disposables.push({ dispose: () => watcher.close() }); + } catch { + // best-effort only — the passive repo.state.onDidChange listener still applies. + } + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + } +} diff --git a/src/proposalController.ts b/src/proposalController.ts index af90a30..792b082 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -380,8 +380,13 @@ export class ProposalController implements vscode.Disposable { /** * F12/#64 (INV-51): ACCEPT an optimistically-applied proposal — the text is - * already in the buffer, so this only advances the F6 baseline (machine-landing, - * via `attribution.signalLanded`) and clears the proposal. No re-application. + * already in the buffer, so this only marks the attribution landing (via + * `attribution.signalLanded`) and clears the proposal. No re-application. + * Native-surfaces migration (INV-7/INV-18 retirement, spec §6.4): this no + * longer advances the baseline — a landed proposal stays a visible change- + * since-baseline until commit (head mode) or "Mark Changes as Reviewed" + * (snapshot mode); nothing here subscribes to `signalLanded` for baseline + * purposes anymore (the `onDidApplyAgentEdit → advance` wire was removed). */ async finalizeInPlace(docPath: string, proposalId: string): Promise { const hit = this.byId(docPath, proposalId); diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index c349298..23400b3 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -548,11 +548,14 @@ export interface RenderOptions { /** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */ render?: (src: string) => string; /** - * #48: the baseline was just PINNED (`reason === "pinned"`). With zero changes - * since that pin, the on-render is fully clean — no authorship coloring — so a - * pin reads as "this is my clean starting point". Distinct from a baseline - * advanced by a machine-landing (accept), which keeps its authorship coloring - * (F10 INV-33). Only consulted by `renderReview`. + * #48: the baseline was just PINNED (`reason === "pinned"` — "Mark Changes as + * Reviewed" in snapshot mode). With zero changes since that pin, the + * on-render is fully clean — no authorship coloring — so a pin reads as + * "this is my clean starting point". Distinct from an ordinary accepted + * proposal, which — since the native-surfaces migration retired the + * machine-landing baseline advance (INV-18/INV-7) — stays a visible change- + * since-baseline and keeps its authorship coloring (F10 INV-33). Only + * consulted by `renderReview`. */ pinned?: boolean; } diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index d7b0ffc..a6c0c8e 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -418,15 +418,15 @@ export class TrackChangesPreviewController implements vscode.Disposable { } private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string { - if (!baseline) return "opened (no baseline yet)"; + if (!baseline) return "not coediting (no baseline yet)"; const time = new Date(baseline.capturedAt).toLocaleTimeString(); switch (baseline.reason) { - case "machine-landing": - return `Claude landed ${time}`; + case "head": + return `HEAD ${time}`; case "pinned": - return `pinned ${time}`; + return `reviewed ${time}`; default: - return `opened ${time}`; + return `entered ${time}`; } } diff --git a/test/baselineStore.test.ts b/test/baselineStore.test.ts index 22af488..4f39d31 100644 --- a/test/baselineStore.test.ts +++ b/test/baselineStore.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { BaselineStore, type Baseline } from "../src/baselineStore"; +import { BaselineStore, normalizeReason, type Baseline } from "../src/baselineStore"; let dir: string; @@ -16,9 +16,23 @@ afterEach(() => { const KEY = "a1b2c3"; // a stand-in for the controller's sha256(uri) key function sample(uri = "file:///ws/notes/chapter-1.md"): Baseline { - return { uri, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }; + return { uri, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "entered" }; } +describe("normalizeReason (INV-7 legacy migration)", () => { + it("migrates legacy 'opened' → 'entered'", () => { + expect(normalizeReason("opened")).toBe("entered"); + }); + it("migrates legacy 'machine-landing' → 'entered'", () => { + expect(normalizeReason("machine-landing")).toBe("entered"); + }); + it("passes current reasons through unchanged", () => { + expect(normalizeReason("entered")).toBe("entered"); + expect(normalizeReason("pinned")).toBe("pinned"); + expect(normalizeReason("head")).toBe("head"); + }); +}); + describe("BaselineStore", () => { it("returns null for a key with no baseline", () => { expect(new BaselineStore(dir).load(KEY)).toBeNull(); @@ -36,9 +50,31 @@ describe("BaselineStore", () => { expect(store.load(KEY)).toEqual(b); }); + it("migrates a legacy on-disk reason ('opened') to 'entered' on load", () => { + const store = new BaselineStore(dir); + fs.mkdirSync(path.dirname(store.baselinePath(KEY)), { recursive: true }); + fs.writeFileSync( + store.baselinePath(KEY), + JSON.stringify({ uri: "file:///a.md", text: "legacy\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }), + "utf8", + ); + expect(store.load(KEY)?.reason).toBe("entered"); + }); + + it("migrates a legacy on-disk reason ('machine-landing') to 'entered' on load", () => { + const store = new BaselineStore(dir); + fs.mkdirSync(path.dirname(store.baselinePath(KEY)), { recursive: true }); + fs.writeFileSync( + store.baselinePath(KEY), + JSON.stringify({ uri: "file:///a.md", text: "legacy\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "machine-landing" }), + "utf8", + ); + expect(store.load(KEY)?.reason).toBe("entered"); + }); + it("overwrites in place: the newest epoch wins, no history kept", () => { const store = new BaselineStore(dir); - store.save(KEY, { uri: "untitled:Untitled-1", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }); + store.save(KEY, { uri: "untitled:Untitled-1", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "entered" }); store.save(KEY, { uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" }); expect(store.load(KEY)).toEqual({ uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" }); }); diff --git a/test/e2e/suite-no-workspace/noWorkspace.test.ts b/test/e2e/suite-no-workspace/noWorkspace.test.ts index 7c645e9..3f29e83 100644 --- a/test/e2e/suite-no-workspace/noWorkspace.test.ts +++ b/test/e2e/suite-no-workspace/noWorkspace.test.ts @@ -79,22 +79,25 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => { // F6 (#19) baseline data layer is workspace-INDEPENDENT: it captures a baseline // for an untitled buffer even with no folder open (the two-pane VIEW was - // removed in #34; only the data layer remains). pinDiffBaseline stays real. + // removed in #34; only the data layer remains). markReviewed stays real. + // Native-surfaces migration (Task 2, INV-7): the baseline is established on + // coediting ENTRY, not merely on open. test("F6 baseline data layer works with no folder open (untitled buffer)", async () => { const all = await vscode.commands.getCommands(true); - assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline registered"); + assert.ok(all.includes("cowriting.markReviewed"), "markReviewed registered"); const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const api = (await ext.activate()) as CowritingApi; const untitled = await vscode.workspace.openTextDocument({ content: "no-folder scratch\n", language: "markdown" }); await vscode.window.showTextDocument(untitled); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await new Promise((r) => setTimeout(r, 300)); const key = untitled.uri.toString(); const baseline = api.diffViewController.getBaseline(key); assert.ok(baseline, "baseline captured for the untitled buffer with no folder"); - assert.strictEqual(baseline!.reason, "opened"); - // pin resets the baseline to now — works folder-less. - await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); + assert.strictEqual(baseline!.reason, "entered"); + // markReviewed resets the baseline to now — works folder-less. + await vscode.commands.executeCommand("cowriting.markReviewed"); await new Promise((r) => setTimeout(r, 300)); - assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "pin works with no folder"); + assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "markReviewed works with no folder"); }); }); diff --git a/test/e2e/suite/authorship.test.ts b/test/e2e/suite/authorship.test.ts index efaa760..64d2ec9 100644 --- a/test/e2e/suite/authorship.test.ts +++ b/test/e2e/suite/authorship.test.ts @@ -16,10 +16,12 @@ async function getApi(): Promise { // F10 host E2E (no LLM): the rewrite of the obsolete F9 authorship-mode test. // F9's "authorship" mode / renderAuthorship is gone — the on-state renderReview -// now author-colors Claude's PENDING proposal blocks as cw-ins-claude. After the -// proposal is accepted the baseline advances (INV-18/F12) and the text becomes -// "unchanged" → renders plain. The class to check is cw-ins-claude (proposal block), -// NOT cw-by-claude (the old F9 authorship render). Owns its own markdown doc. +// now author-colors Claude's PENDING proposal blocks as cw-ins-claude. This +// suite checks that render BEFORE accepting (the class to check is cw-ins-claude, +// the proposal block, NOT cw-by-claude — the old F9 authorship render) and that +// the attribution data layer survives the accept; it does not exercise the F6 +// baseline (see diffView.test.ts / trackChangesPreview.test.ts / baselineRouter.test.ts +// for the post-accept baseline behavior, INV-7/D21). Owns its own markdown doc. suite("F10 review preview — pending Claude proposal renders cw-ins-claude in the on-state (host E2E, no LLM)", () => { const DOC_REL = "docs/f10claude.md"; const TARGET = "The sentence Claude will compose over."; @@ -65,7 +67,9 @@ suite("F10 review preview — pending Claude proposal renders cw-ins-claude in t "pending Claude proposal block carries cw-ins-claude in the on-state render", ); - // accept via the seam — baseline advances (machine-landing, INV-18/F12) + // accept via the seam — the proposal lands; the baseline no longer auto-advances + // (INV-18/#48 retired, spec INV-7), but that isn't asserted here (no coediting + // entry / baseline established for this doc — only the attribution data layer). assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam"); await settle(); diff --git a/test/e2e/suite/baselineRouter.test.ts b/test/e2e/suite/baselineRouter.test.ts new file mode 100644 index 0000000..cf80c35 --- /dev/null +++ b/test/e2e/suite/baselineRouter.test.ts @@ -0,0 +1,48 @@ +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"; +import { activateApi, settle, settleUntil } from "./helpers"; + +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"); + }); +}); diff --git a/test/e2e/suite/diffView.test.ts b/test/e2e/suite/diffView.test.ts index 7758ac9..4ae7eeb 100644 --- a/test/e2e/suite/diffView.test.ts +++ b/test/e2e/suite/diffView.test.ts @@ -15,6 +15,16 @@ async function openDoc(): Promise { await vscode.window.showTextDocument(doc); return doc; } +/** Open the doc AND enter coediting (INV-10) — baselines are established only + * on entry (Task 2). Idempotent across tests: re-entering an already-coedited + * doc is a no-op, so the once-captured baseline survives (order-dependent + * suite, same as before). */ +async function openAndCoedit(): Promise { + const doc = await openDoc(); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settle(); + return doc; +} async function getApi(): Promise { const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const api = (await ext.activate()) as CowritingApi; @@ -29,22 +39,29 @@ const settle = () => new Promise((r) => setTimeout(r, 300)); // later tests consume earlier state. Owns docs/diffview.md exclusively. The // baseline works on ANY file (#19), so the last two tests use an out-of-workspace // file and an untitled buffer. +// +// Native-surfaces migration (Task 2, spec §6.4/INV-7): a baseline is now +// established only once a document ENTERS coediting (not merely opened), and +// the fixture workspace is NOT a git repo, so every doc here resolves to +// SNAPSHOT mode. The shipped machine-landing baseline advance (#48/INV-18) is +// retired — an accepted proposal now stays a visible change-since-baseline +// until "Mark Changes as Reviewed" (D21). suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, no LLM)", () => { const TARGET = "A target sentence Claude will rewrite via the seam."; const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam."; - test("opening a tracked doc captures an `opened` baseline equal to the buffer (INV-18)", async () => { - const doc = await openDoc(); - await getApi(); + test("entering coediting on a snapshot-mode doc captures an `entered` baseline equal to the buffer (INV-7)", async () => { + const doc = await openAndCoedit(); const api = await getApi(); + assert.strictEqual(api.diffViewController.modeOf(docUri()), "snapshot", "no git repo → snapshot mode"); const baseline = api.diffViewController.getBaseline(docUri()); - assert.ok(baseline, "baseline captured on first sight"); - assert.strictEqual(baseline!.reason, "opened"); - assert.strictEqual(baseline!.text, doc.getText(), "baseline = open-time buffer"); + assert.ok(baseline, "baseline captured on coediting entry"); + assert.strictEqual(baseline!.reason, "entered"); + assert.strictEqual(baseline!.text, doc.getText(), "baseline = entry-time buffer"); }); test("typing leaves the baseline unchanged while the buffer diverges", async () => { - const doc = await openDoc(); + const doc = await openAndCoedit(); const api = await getApi(); const before = api.diffViewController.getBaseline(docUri())!.text; const edit = new vscode.WorkspaceEdit(); @@ -55,9 +72,10 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, assert.notStrictEqual(doc.getText(), before, "buffer diverged"); }); - test("accepting a proposal advances the baseline past the landed text (PUC-2, INV-18)", async () => { - const doc = await openDoc(); + test("accepting a proposal does NOT advance the baseline — the landed text stays a change (INV-7/D21, #48 retired)", async () => { + const doc = await openAndCoedit(); const api = await getApi(); + const before = api.diffViewController.getBaseline(docUri())!; const start = doc.getText().indexOf(TARGET); assert.ok(start >= 0, "fixture contains the target"); const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { @@ -73,14 +91,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam"); await settle(); const baseline = api.diffViewController.getBaseline(docUri())!; - assert.strictEqual(baseline.reason, "machine-landing", "baseline advanced on the landing"); - assert.ok(baseline.text.includes(REPLACEMENT), "landed text is in the baseline (won't show as a change)"); - assert.ok(!baseline.text.includes(TARGET), "old target gone from the baseline too"); - assert.strictEqual(baseline.text, doc.getText(), "baseline == buffer right after the landing"); + assert.strictEqual(baseline.reason, before.reason, "baseline reason untouched by the landing"); + assert.strictEqual(baseline.text, before.text, "baseline text untouched by the landing"); + assert.ok(!baseline.text.includes(REPLACEMENT), "landed text is NOT folded into the baseline"); + assert.notStrictEqual(baseline.text, doc.getText(), "baseline != buffer — the landing reads as a change"); }); - test("an operator edit after the landing makes baseline ≠ buffer (the operator delta)", async () => { - const doc = await openDoc(); + test("an operator edit after the landing keeps baseline ≠ buffer (the operator delta)", async () => { + const doc = await openAndCoedit(); const api = await getApi(); const edit = new vscode.WorkspaceEdit(); edit.insert(doc.uri, new vscode.Position(0, 0), "POST-LANDING OPERATOR LINE\n"); @@ -89,14 +107,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, assert.notStrictEqual( api.diffViewController.getBaseline(docUri())!.text, doc.getText(), - "operator changes show against the advanced baseline", + "operator changes show against the untouched baseline", ); }); - test("pin resets the baseline to now: baseline == buffer, reason pinned", async () => { - const doc = await openDoc(); + test("markReviewed resets the baseline to now: baseline == buffer, reason pinned", async () => { + const doc = await openAndCoedit(); const api = await getApi(); - await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); + await vscode.commands.executeCommand("cowriting.markReviewed"); await settle(); const baseline = api.diffViewController.getBaseline(docUri())!; assert.strictEqual(baseline.reason, "pinned"); @@ -104,14 +122,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, }); test("the baseline is persisted in GLOBAL storage, never the repo (INV-19)", async () => { - await openDoc(); + await openAndCoedit(); const api = await getApi(); const p = api.diffViewController.baselineFilePath(docUri()); assert.ok(p, "storage-backed baseline path is available for a file: doc"); assert.ok(fs.existsSync(p!), `baseline file exists at ${p}`); const onDisk = JSON.parse(fs.readFileSync(p!, "utf8")); assert.strictEqual(onDisk.uri, docUri(), "baseline records the document URI"); - assert.strictEqual(onDisk.reason, "pinned", "last epoch (pin) persisted"); + assert.strictEqual(onDisk.reason, "pinned", "last epoch (markReviewed) persisted"); assert.strictEqual(onDisk.text, api.diffViewController.getBaseline(docUri())!.text, "on-disk == in-memory"); // INV-19: baseline lives under the extension's storage dir, not the repo. assert.ok(!p!.includes(`${path.sep}.threads${path.sep}`), "baseline is NOT in the sidecar tree"); @@ -127,11 +145,12 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, const outsideUri = vscode.Uri.file(outsidePath); const doc = await vscode.workspace.openTextDocument(outsideUri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); - // Captured on open even though it is NOT under the workspace folder. + // Captured on coediting entry even though it is NOT under the workspace folder. const baseline = api.diffViewController.getBaseline(outsideUri.toString()); assert.ok(baseline, "baseline captured for an out-of-folder file"); - assert.strictEqual(baseline!.reason, "opened"); + assert.strictEqual(baseline!.reason, "entered"); const fp = api.diffViewController.baselineFilePath(outsideUri.toString()); assert.ok(fp && fs.existsSync(fp), "outside-file baseline persisted in global storage"); assert.ok(!fp!.startsWith(WS + path.sep), "not under the workspace folder"); @@ -143,6 +162,7 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, const api = await getApi(); const untitled = await vscode.workspace.openTextDocument({ content: "scratch line\n", language: "markdown" }); await vscode.window.showTextDocument(untitled); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); const key = untitled.uri.toString(); assert.strictEqual(untitled.uri.scheme, "untitled", "it really is an untitled buffer"); diff --git a/test/e2e/suite/f10Review.test.ts b/test/e2e/suite/f10Review.test.ts index bbe9928..7d733c3 100644 --- a/test/e2e/suite/f10Review.test.ts +++ b/test/e2e/suite/f10Review.test.ts @@ -15,7 +15,9 @@ async function getApi(): Promise { return api; } -/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */ +/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. + * Also enters coediting (INV-10/Task 2): the baseline is established only on + * entry, not merely on open. */ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { const abs = path.join(WS, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); @@ -23,6 +25,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } @@ -114,7 +117,7 @@ suite("F10 interactive review (host E2E — preview is the single review surface assert.ok(doc.getText().includes("A FIRST claude REPLACEMENT sentence."), "F12 optimistic-apply: replacement in buffer"); }); - test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => { + test("accept → the proposal lands, clears from the preview, and stays a visible change-since-baseline (PUC-4, INV-7/D21)", async () => { const { doc, key } = await reopen(DOC_REL); const api = await getApi(); const id = api.proposalController.listProposals(doc).find((v) => v.replaced === T1)!.id; @@ -126,10 +129,12 @@ suite("F10 interactive review (host E2E — preview is the single review surface assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id), "proposal cleared from listProposals"); const html = api.trackChangesPreviewController.renderHtmlFor(key); assert.ok(!html.includes(`data-proposal-id="${id}"`), "the accepted proposal block is gone from the preview"); - // The baseline advanced on the landing (INV-18): the landed text is not marked. + // Native-surfaces migration (INV-7/D21): the shipped machine-landing baseline + // advance (#48/INV-18) is retired — the landed text stays a visible change + // until "Mark Changes as Reviewed". const model = api.trackChangesPreviewController.getLastModel(key) ?? []; const marked = model.some((o) => o.kind !== "unchanged" && o.block.raw.includes("FIRST claude REPLACEMENT")); - assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)"); + assert.ok(marked, "the just-landed Claude text still renders as a change (baseline does not auto-advance)"); }); test("reject → the proposal vanishes and the document is reverted (PUC-5)", async () => { @@ -228,8 +233,8 @@ suite("F10 interactive review (host E2E — preview is the single review surface (k) => k.command === "cowriting.toggleDiffView", ); assert.ok(!dKb, "the toggleDiffView keybinding is gone from package.json (#34)"); - // …but the F6 baseline data layer survives: pinDiffBaseline stays a real command. - assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline (baseline data layer) is kept"); + // …but the F6 baseline data layer survives: markReviewed stays a real command. + assert.ok(all.includes("cowriting.markReviewed"), "markReviewed (baseline data layer) is kept"); void key; }); @@ -297,6 +302,9 @@ async function reopen(rel: string): Promise<{ doc: vscode.TextDocument; key: str const uri = vscode.Uri.file(path.join(WS, rel)); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + // Idempotent (INV-10): the doc entered coediting in freshDoc(); re-entering + // is a no-op that leaves the once-captured baseline untouched. + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } diff --git a/test/e2e/suite/f11Toolbar.test.ts b/test/e2e/suite/f11Toolbar.test.ts index 31e69d8..ed5f01b 100644 --- a/test/e2e/suite/f11Toolbar.test.ts +++ b/test/e2e/suite/f11Toolbar.test.ts @@ -14,7 +14,9 @@ async function getApi(): Promise { return api; } -/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */ +/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. + * Also enters coediting (INV-10/Task 2): the baseline is established only on + * entry, not merely on open. */ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { const abs = path.join(WS, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); @@ -22,6 +24,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } @@ -57,12 +60,13 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () = }); // SLICE-1 reachability: the orphaned pin command gets a real palette `when`. - test("pinDiffBaseline is reachable from the command palette (when: editorLangId == markdown)", async () => { + // Renamed cowriting.pinDiffBaseline → cowriting.markReviewed (Task 2, D14). + test("markReviewed is reachable from the command palette (when: editorLangId == markdown)", async () => { const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find( - (m) => m.command === "cowriting.pinDiffBaseline", + (m) => m.command === "cowriting.markReviewed", ); - assert.ok(entry, "pinDiffBaseline has a commandPalette entry"); + assert.ok(entry, "markReviewed has a commandPalette entry"); assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)"); assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); }); diff --git a/test/e2e/suite/f12InlineDiff.test.ts b/test/e2e/suite/f12InlineDiff.test.ts index 3fe8599..07ae483 100644 --- a/test/e2e/suite/f12InlineDiff.test.ts +++ b/test/e2e/suite/f12InlineDiff.test.ts @@ -16,6 +16,8 @@ async function getApi(): Promise { return api; } +/** Also enters coediting (INV-10/Task 2): the baseline is established only on + * entry, not merely on open. */ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { const abs = path.join(WS, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); @@ -23,6 +25,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } diff --git a/test/e2e/suite/helpers.ts b/test/e2e/suite/helpers.ts new file mode 100644 index 0000000..a976ee8 --- /dev/null +++ b/test/e2e/suite/helpers.ts @@ -0,0 +1,32 @@ +/** + * Shared host-E2E helpers (native-surfaces migration, Task 2). NEW suites use + * these; existing suites keep their own local copies (out of scope to + * refactor them onto this shared file — see the Task 2 brief). + */ +import * as assert from "node:assert"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; + +/** Activate the extension and return its exported API (asserts it is real). */ +export async function activateApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.diffViewController, "extension exports diffViewController"); + return api; +} + +/** A short fixed settle, for state that updates synchronously-ish after a command. */ +export function settle(): Promise { + return new Promise((r) => setTimeout(r, 150)); +} + +/** Poll `predicate` every 100ms until it is true, or fail after `timeoutMs`. */ +export async function settleUntil(predicate: () => boolean, timeoutMs = 5000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + assert.fail(`settleUntil: predicate did not become true within ${timeoutMs}ms`); + } + await new Promise((r) => setTimeout(r, 100)); + } +} diff --git a/test/e2e/suite/s48PinClean.test.ts b/test/e2e/suite/s48PinClean.test.ts index a626f53..c524316 100644 --- a/test/e2e/suite/s48PinClean.test.ts +++ b/test/e2e/suite/s48PinClean.test.ts @@ -14,6 +14,8 @@ async function getApi(): Promise { return api; } +/** Also enters coediting (INV-10/Task 2): the baseline is established only on + * entry, not merely on open. */ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { const abs = path.join(WS, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); @@ -21,6 +23,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } diff --git a/test/e2e/suite/trackChangesPreview.test.ts b/test/e2e/suite/trackChangesPreview.test.ts index 3d6cf95..26a1797 100644 --- a/test/e2e/suite/trackChangesPreview.test.ts +++ b/test/e2e/suite/trackChangesPreview.test.ts @@ -7,10 +7,14 @@ const WS = process.env.E2E_WORKSPACE!; const DOC_REL = "docs/preview.md"; const docUri = () => vscode.Uri.file(path.join(WS, DOC_REL)).toString(); +/** Open the doc AND enter coediting (INV-10/Task 2) — the baseline is + * established only on entry; re-entering an already-coedited doc is a no-op + * so the once-captured baseline survives across this order-dependent suite. */ async function openDoc(rel = DOC_REL): Promise { const uri = vscode.Uri.file(path.join(WS, rel)); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); return doc; } async function getApi(): Promise { @@ -58,7 +62,7 @@ suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, ); }); - test("accepting a proposal advances the baseline; the landed block goes unchanged (PUC-3, INV-18)", async () => { + test("accepting a proposal does NOT advance the baseline; the landed block stays marked (PUC-3, INV-7/D21, #48 retired)", async () => { const doc = await openDoc(); const api = await getApi(); const start = doc.getText().indexOf(TARGET); @@ -75,23 +79,24 @@ suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, assert.ok(id, "propose returns an id"); assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam"); await settle(); - // The baseline advanced to include REPLACEMENT, so the buffer == baseline for - // that block → it is NOT marked. Confirm the replacement is not flagged as a change. + // Native-surfaces migration (INV-7/D21): the shipped machine-landing baseline + // advance (#48/INV-18) is retired — the landed block stays a visible change + // until "Mark Changes as Reviewed". const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? []; const replacementMarked = model.some( (o) => o.kind !== "unchanged" && o.block.raw.includes("CLAUDE REWROTE"), ); - assert.ok(!replacementMarked, "the just-landed text renders unmarked (baseline advanced)"); + assert.ok(replacementMarked, "the just-landed text still renders marked (baseline does not auto-advance)"); }); - test("pin resets the baseline to now → preview shows no marks (PUC-4)", async () => { + test("markReviewed resets the baseline to now → preview shows no marks (PUC-4)", async () => { const doc = await openDoc(); const api = await getApi(); - await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); + await vscode.commands.executeCommand("cowriting.markReviewed"); await settle(); assert.ok( kinds(api).every((k) => k === "unchanged"), - "after pin, every block is unchanged (baseline == buffer)", + "after markReviewed, every block is unchanged (baseline == buffer)", ); void doc; }); @@ -112,7 +117,7 @@ suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, // Show the preview and pin the baseline so the fixture's `a --> b` flowchart // is the "before"; then add an edge `a --> c` and confirm the intra-diagram diff. await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); - await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); + await vscode.commands.executeCommand("cowriting.markReviewed"); await settle(); const anchor = doc.getText().indexOf("a --> b"); diff --git a/test/e2e/suite/undoMarks.test.ts b/test/e2e/suite/undoMarks.test.ts index 1404e4d..1acc7ee 100644 --- a/test/e2e/suite/undoMarks.test.ts +++ b/test/e2e/suite/undoMarks.test.ts @@ -15,6 +15,9 @@ async function getApi(): Promise { return api; } +/** Also enters coediting (INV-10/Task 2): the baseline is established only on + * entry, not merely on open — needed for the preview's added/changed marks to + * reflect a real diff rather than a vacuous baseline==current fallback. */ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { const abs = path.join(WS, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); @@ -22,6 +25,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 8ae493f..c00f92d 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -440,9 +440,9 @@ describe("renderReview", () => { expect(html).toContain('data-proposal-id="p1"'); // proposal still shows (it is an action) expect(html).toContain("Person"); }); - test("renderReview: zero diff (e.g. machine-landing accept) renders unchanged blocks plain (Task 2 behavior)", () => { + test("renderReview: zero diff (baseline == current) renders unchanged blocks plain (Task 2 behavior)", () => { // Task 2: unchanged blocks always render plain — color means "changed since baseline". - // After accepting a Claude edit the text is the new baseline; no diff → no coloring. + // When baseline == current (e.g. right after a fresh snapshot) there's no diff → no coloring. const doc = "Human wrote this.\n\nClaude wrote that."; const spans: AuthorSpan[] = [{ start: 19, end: doc.length, author: "claude" }]; const html = renderReview(doc, doc, spans, []); // no pinned flag -- 2.39.5 From cf655287119aa6b5c9245e12d3097cb5c8f45597 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 07:18:50 -0700 Subject: [PATCH 04/18] =?UTF-8?q?feat:=20native=20diff=20surface=20?= =?UTF-8?q?=E2=80=94=20QuickDiff=20+=20cowriting-baseline:=20provider=20+?= =?UTF-8?q?=20Review=20Changes=20+=20status=20bar=20(spec=20=C2=A76.4,=20I?= =?UTF-8?q?NV-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- package.json | 19 +++- src/extension.ts | 20 +++++ src/scmSurface.ts | 120 ++++++++++++++++++++++++++ src/trackChangesModel.ts | 24 ++++++ test/e2e/suite/baselineRouter.test.ts | 4 + test/e2e/suite/coediting.test.ts | 15 ++++ test/trackChangesModel.test.ts | 18 +++- 7 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 src/scmSurface.ts diff --git a/package.json b/package.json index eb44367..a889b3d 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,14 @@ { "command": "cowriting.markReviewed", "title": "Mark Changes as Reviewed", - "category": "Cowriting" + "category": "Cowriting", + "icon": "$(check-all)" + }, + { + "command": "cowriting.reviewChanges", + "title": "Review Changes", + "category": "Cowriting", + "icon": "$(git-compare)" }, { "command": "cowriting.showTrackChangesPreview", @@ -182,6 +189,16 @@ } ], "editor/title": [ + { + "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" + }, { "command": "cowriting.showTrackChangesPreview", "when": "editorLangId == markdown", diff --git a/src/extension.ts b/src/extension.ts index 410a504..eb73135 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -16,6 +16,7 @@ import { LiveProgressUi } from "./liveProgressUi"; import { EditorProposalController } from "./editorProposalController"; import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath"; import { CoeditingRegistry } from "./coeditingRegistry"; +import { ScmSurfaceController } from "./scmSurface"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; @@ -30,6 +31,7 @@ export interface CowritingApi { liveProgressUi: LiveProgressUi; editorProposalController: EditorProposalController; coeditingRegistry: CoeditingRegistry; + scmSurfaceController: ScmSurfaceController; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { @@ -105,6 +107,23 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const diffViewController = new DiffViewController(baselineStore, gitBaseline, coeditingRegistry); context.subscriptions.push(diffViewController); + // Task 1 hook: expose the resolved baseline mode ("head"/"snapshot") to the + // registry's context-key sync, and re-sync `cowriting.baselineMode` whenever + // a baseline is (re)captured (entry, head-refresh, pin) — the "Mark Changes + // as Reviewed" menu entry (snapshot-only, Task 3) is gated on that key. + coeditingRegistry.baselineModeOf = (uri) => diffViewController.modeOf(uri.toString()); + context.subscriptions.push( + diffViewController.onDidChangeBaseline(() => coeditingRegistry.syncContext(vscode.window.activeTextEditor)), + ); + + // --- Task 3: the native diff surface (spec §6.4/§5, INV-13) — QuickDiff + // gutter bars, the cowriting-baseline: content provider, "Review Changes", + // and the status-bar change count. Constructed after diffViewController + // (consumes getBaseline/modeOf/onDidChangeBaseline) and the registry + // (consumes isCoediting/onDidChange), gated on INV-10. --- + const scmSurfaceController = new ScmSurfaceController(coeditingRegistry, diffViewController); + context.subscriptions.push(scmSurfaceController); + // F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage // home as the F6 baseline, keyed by sha256(uri) (INV-19/24). Constructed // workspace-independently; the router falls back to it for any non-in-folder @@ -396,6 +415,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef liveProgressUi, editorProposalController, coeditingRegistry, + scmSurfaceController, }; } diff --git a/src/scmSurface.ts b/src/scmSurface.ts new file mode 100644 index 0000000..9409fee --- /dev/null +++ b/src/scmSurface.ts @@ -0,0 +1,120 @@ +/** + * 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(); + private readonly baselineEmitter = new vscode.EventEmitter(); + private readonly recountTimers = new Map>(); + + 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 { + 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(); + } +} diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 23400b3..7c4c3a6 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -193,6 +193,30 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[] return ops; } +/** + * #64/Task 3 (INV-6): the number of line-grain hunks between `oldText` and + * `newText` — a run of one or more contiguous non-equal lines counts as ONE + * hunk (the status-bar "N changes" count). Same LCS walk `diffBlocks` uses + * (jsdiff `diffArrays`), at line grain instead of block grain. Pure, + * vscode-free, deterministic. + */ +export function countLineHunks(oldText: string, newText: string): number { + const before = oldText.split(/\r?\n/); + const after = newText.split(/\r?\n/); + const changes = diffArrays(before, after); + let hunks = 0; + let inHunk = false; + for (const ch of changes) { + if (ch.added || ch.removed) { + if (!inHunk) hunks++; + inHunk = true; + } else { + inHunk = false; + } + } + return hunks; +} + /** A contiguous changed region of `currentText` and its replacement (F11). */ export interface EditHunk { /** char offset of the hunk's first changed char in currentText. */ diff --git a/test/e2e/suite/baselineRouter.test.ts b/test/e2e/suite/baselineRouter.test.ts index cf80c35..2ba199d 100644 --- a/test/e2e/suite/baselineRouter.test.ts +++ b/test/e2e/suite/baselineRouter.test.ts @@ -16,10 +16,14 @@ suite("baseline router (PUC-1, INV-7/D13/D14)", () => { 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")); + // Task 3 (INV-13): the status-bar/SCM change count tracks the live edit. + await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 1); 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"); + // A pin re-baselines to the clean current text, so the change count resets. + await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 0); }); test("head mode: baseline = HEAD; commit advances it", async function () { diff --git a/test/e2e/suite/coediting.test.ts b/test/e2e/suite/coediting.test.ts index cd95bbc..ff99efe 100644 --- a/test/e2e/suite/coediting.test.ts +++ b/test/e2e/suite/coediting.test.ts @@ -1,5 +1,6 @@ import * as assert from "node:assert"; import * as vscode from "vscode"; +import { activateApi, settle } from "./helpers"; async function api() { const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; @@ -17,4 +18,18 @@ suite("coediting registry (PUC-7)", () => { await vscode.commands.executeCommand("cowriting.stopCoediting"); assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), false); }); + + // Task 3 (INV-10): a document that was never entered into coediting gets no + // native-diff surface at all. QuickDiff's `provideOriginalResource` isn't + // directly queryable from a host-E2E test, so this asserts the gate INPUTS + // structurally: `isCoediting` stays false and the SCM change count — which + // `ScmSurfaceController` only ever populates for a coediting doc — stays 0. + test("non-entered doc: no coediting, no change count (structural, INV-10)", async () => { + const api = await activateApi(); + const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "untouched\n" }); + await vscode.window.showTextDocument(doc); + await settle(); + assert.strictEqual(api.coeditingRegistry.isCoediting(doc.uri), false); + assert.strictEqual(api.scmSurfaceController.changeCount(doc.uri.toString()), 0); + }); }); diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index c00f92d..89cae0d 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -1,6 +1,6 @@ import { describe, it, test, expect } from "vitest"; import MarkdownIt from "markdown-it"; -import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, type AuthorSpan } from "../src/trackChangesModel"; +import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, countLineHunks, type AuthorSpan } from "../src/trackChangesModel"; const md = new MarkdownIt({ html: true, linkify: false, breaks: false }); describe("splitBlocks", () => { @@ -825,3 +825,19 @@ describe("F11 data-src emission (INV-36)", () => { expect(renderReview(doc, doc, [], [])).not.toContain('href="https://example.com/spec"'); }); }); + +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); + }); +}); -- 2.39.5 From a323b827a8c8a71fd46bca375871f515d9a8d59d Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 07:41:13 -0700 Subject: [PATCH 05/18] =?UTF-8?q?feat:=20gate=20every=20surface=20on=20Coe?= =?UTF-8?q?ditingRegistry=20(INV-10)=20=E2=80=94=20commenting=20ranges=20r?= =?UTF-8?q?e-assigned=20on=20gate=20change=20(spec=20=C2=A76.4=20v0.2.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- package.json | 12 +++---- src/attributionController.ts | 4 +++ src/editorProposalController.ts | 15 +++++++++ src/extension.ts | 32 ++++++++++++++++--- src/proposalController.ts | 11 ++++++- src/threadController.ts | 46 +++++++++++++++++++++++---- src/trackChangesPreview.ts | 12 +++++++ test/e2e/suite/attribution.test.ts | 3 ++ test/e2e/suite/authorship.test.ts | 3 ++ test/e2e/suite/coediting.test.ts | 23 ++++++++++++++ test/e2e/suite/crossrung.test.ts | 2 ++ test/e2e/suite/f12Accept.test.ts | 3 ++ test/e2e/suite/f12Reach.test.ts | 6 ++++ test/e2e/suite/f12Review.test.ts | 3 ++ test/e2e/suite/liveProgress.test.ts | 3 ++ test/e2e/suite/outOfWorkspace.test.ts | 4 +++ test/e2e/suite/proposals.test.ts | 4 +++ test/e2e/suite/threads.test.ts | 3 ++ 18 files changed, 171 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index a889b3d..bd15bcf 100644 --- a/package.json +++ b/package.json @@ -173,11 +173,11 @@ }, { "command": "cowriting.acceptAllProposals", - "when": "editorLangId == markdown" + "when": "editorLangId == markdown && cowriting.isCoediting" }, { "command": "cowriting.rejectAllProposals", - "when": "editorLangId == markdown" + "when": "editorLangId == markdown && cowriting.isCoediting" }, { "command": "cowriting.proposalAcceptMenu", @@ -208,7 +208,7 @@ "editor/title/context": [ { "command": "cowriting.edit", - "when": "resourceLangId == markdown", + "when": "resourceLangId == markdown && cowriting.isCoediting", "group": "1_cowriting@1" }, { @@ -227,12 +227,12 @@ "editor/context": [ { "command": "cowriting.edit", - "when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)", + "when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting", "group": "1_cowriting@1" }, { "command": "cowriting.createThread", - "when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)", + "when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting", "group": "1_cowriting@2" }, { @@ -277,7 +277,7 @@ "command": "cowriting.edit", "key": "ctrl+alt+e", "mac": "cmd+alt+e", - "when": "editorTextFocus && editorLangId == markdown" + "when": "editorTextFocus && editorLangId == markdown && cowriting.isCoediting" } ] }, diff --git a/src/attributionController.ts b/src/attributionController.ts index 94f759f..528a67e 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -21,6 +21,7 @@ import { minimizeReplace, PendingEditRegistry } from "./pendingEdits"; import type { VersionGuard } from "./versionGuard"; import { isAuthorable } from "./workspacePath"; import type { AuthorSpan } from "./trackChangesModel"; +import type { CoeditingRegistry } from "./coeditingRegistry"; /** Test-facing snapshot of live attribution state for a document. */ export interface RenderedSpan { @@ -69,6 +70,7 @@ export class AttributionController implements vscode.Disposable { private readonly store: SidecarRouter, private readonly rootDir: string | undefined, private readonly guard: VersionGuard, + private readonly registry: CoeditingRegistry, ) { this.disposables.push(this.statusItem, this.output, this.applyEmitter); this.disposables.push( @@ -104,6 +106,7 @@ export class AttributionController implements vscode.Disposable { /** Load the sidecar and re-resolve every attribution (live span | orphan). */ loadAll(document: vscode.TextDocument): void { if (!this.isTracked(document)) return; + if (!this.registry.isCoediting(document.uri)) return; const docPath = this.keyOf(document); const s = this.state(docPath); const artifact = this.store.load(docPath); @@ -145,6 +148,7 @@ export class AttributionController implements vscode.Disposable { private onDidChange(e: vscode.TextDocumentChangeEvent): void { if (!this.isTracked(e.document) || e.contentChanges.length === 0) return; + if (!this.registry.isCoediting(e.document.uri)) return; const docPath = this.keyOf(e.document); if (!e.document.isDirty && this.matchesDisk(e.document)) { // Disk sync (revert / external reload): buffer now equals the file on diff --git a/src/editorProposalController.ts b/src/editorProposalController.ts index 370b2c5..b9489a3 100644 --- a/src/editorProposalController.ts +++ b/src/editorProposalController.ts @@ -17,6 +17,7 @@ import type { DiffViewController } from "./diffViewController"; import type { AttributionController } from "./attributionController"; import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel"; import { isAuthorable } from "./workspacePath"; +import type { CoeditingRegistry } from "./coeditingRegistry"; export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider { private readonly disposables: vscode.Disposable[] = []; @@ -51,6 +52,7 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL private readonly proposals: ProposalController, private readonly diffView: DiffViewController, private readonly attribution: AttributionController, + private readonly registry: CoeditingRegistry, ) { this.disposables.push( this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter, @@ -67,6 +69,13 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL const ed = vscode.window.activeTextEditor; if (ed && e.document === ed.document) this.scheduleRender(ed); }), + // INV-10: re-render (and refresh CodeLens) on every gate change — exit clears + // decorations/CodeLens via renderEditor's own gate check, re-entry redraws. + this.registry.onDidChange(({ uri }) => { + const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri); + if (ed) this.renderEditor(ed); + this.lensEmitter.fire(); + }), // the four QuickPick-backed menu commands vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)), vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)), @@ -106,6 +115,9 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL private async onProposalsChanged(uri: string): Promise { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); if (!doc || doc.languageId !== "markdown" || !isAuthorable(doc.uri.scheme)) return; + // INV-10: a doc that exited coediting between the schedule and this tick gets + // no optimistic-apply/decorations/CodeLens. + if (!this.registry.isCoediting(doc.uri)) return; const key = this.proposals.keyFor(doc); for (const v of this.proposals.listProposals(doc)) { if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) { @@ -125,6 +137,8 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []); }; if (doc.languageId !== "markdown") return clearAll(); + // INV-10: a non-entered doc gets no decorations at all. + if (!this.registry.isCoediting(doc.uri)) return clearAll(); const key = this.proposals.keyFor(doc); const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] }; const del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]> = { human: [], claude: [], none: [] }; @@ -205,6 +219,7 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL /** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */ provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { if (document.languageId !== "markdown") return []; + if (!this.registry.isCoediting(document.uri)) return []; const key = this.proposals.keyFor(document); const lenses: vscode.CodeLens[] = []; for (const v of this.proposals.listProposals(document)) { diff --git a/src/extension.ts b/src/extension.ts index eb73135..cf978a6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,8 @@ import { CoeditingRegistry } from "./coeditingRegistry"; import { ScmSurfaceController } from "./scmSurface"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; +/** The exact warning copy for every gated edit gesture (INV-10). */ +const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first."; export interface CowritingApi { threadController: ThreadController; @@ -144,16 +146,22 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef // F5 (INV-16): one shared guard — newer-major sidecars are read-only, one // warning per doc across the three co-owning controllers. const versionGuard = new VersionGuard(sidecarRouter); - const threadController = new ThreadController(sidecarRouter, root, versionGuard); + const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry); context.subscriptions.push(threadController); // --- F3: live attribution (Feature #6) --- - const attributionController = new AttributionController(sidecarRouter, root, versionGuard); + const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry); context.subscriptions.push(attributionController); // --- F4: propose/accept (Feature #12) — constructed before the preview so F10 // can route ✓/✗ through it --- - const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard); + const proposalController = new ProposalController( + sidecarRouter, + attributionController, + root, + versionGuard, + coeditingRegistry, + ); context.subscriptions.push(proposalController); // --- F7/F10: the review preview is the single interactive review surface --- @@ -166,12 +174,18 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef attributionController, proposalController, liveProgressUi, + coeditingRegistry, ); context.subscriptions.push(trackChangesPreviewController); // --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer, // decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). --- - const editorProposalController = new EditorProposalController(proposalController, diffViewController, attributionController); + const editorProposalController = new EditorProposalController( + proposalController, + diffViewController, + attributionController, + coeditingRegistry, + ); context.subscriptions.push(editorProposalController); // #46 (INV-42): accept every pending proposal on the active doc in one gesture @@ -289,6 +303,11 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef } if (!editor) return; // unreachable once reason is null, but narrows the type const document = editor.document; + // INV-10: a non-entered doc gets the gate warning, not a turn. + if (!coeditingRegistry.isCoediting(document.uri)) { + void vscode.window.showWarningMessage(NOT_COEDITING_WARNING); + return; + } const selection = editor.selection; // non-empty (selectionRejection guaranteed it) // Capture the selection text + anchor BEFORE prompting — the inline prompt // moves the cursor to the document top (where its box anchors), which would @@ -394,8 +413,11 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ); // Render threads + attributions for already-open editors, and on future opens. + // INV-10: gated on the registry too — belt-and-suspenders with the per-controller + // gates (renderAll/loadAll already early-return), but keeps the intent explicit + // at the call site and avoids the no-op calls for a non-entered doc. const renderIfOpen = (doc: vscode.TextDocument) => { - if (isAuthorable(doc.uri.scheme)) { + if (isAuthorable(doc.uri.scheme) && coeditingRegistry.isCoediting(doc.uri)) { threadController.renderAll(doc); attributionController.loadAll(doc); proposalController.renderAll(doc); diff --git a/src/proposalController.ts b/src/proposalController.ts index 792b082..36373a5 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -19,6 +19,7 @@ import type { AttributionController } from "./attributionController"; import type { VersionGuard } from "./versionGuard"; import { isAuthorable } from "./workspacePath"; import { wordEditHunks, type ProposalView } from "./trackChangesModel"; +import type { CoeditingRegistry } from "./coeditingRegistry"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedProposal { @@ -56,6 +57,7 @@ export class ProposalController implements vscode.Disposable { private readonly attribution: AttributionController, private readonly rootDir: string | undefined, private readonly guard: VersionGuard, + private readonly registry: CoeditingRegistry, ) { this.disposables.push(this.statusItem); this.disposables.push(this.onDidChangeProposalsEmitter); @@ -459,7 +461,13 @@ export class ProposalController implements vscode.Disposable { // ---- PUC-4: load / external change / resolve-or-flag -------------------------------- - /** Load + (re)render every pending proposal at its resolved anchor (or flagged). */ + /** Load + (re)render every pending proposal at its resolved anchor (or flagged). + * INV-10: the RENDER portion (live/unresolved recompute, the status bar, and + * the onDidChangeProposals fire that drives F12's optimistic-apply/CodeLens/ + * decorations) is gated on coediting — `propose()` itself stays ungated (it + * always persists), and so does the state LOAD below: `acceptById`/`rejectById` + * key off `this.docs` (populated here) regardless of coediting, since deciding + * an already-persisted proposal is not a "rendering" surface. */ renderAll(document: vscode.TextDocument): void { if (!this.isTracked(document)) return; const docPath = this.keyOf(document); @@ -467,6 +475,7 @@ export class ProposalController implements vscode.Disposable { state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); state.live.clear(); state.unresolved.clear(); + if (!this.registry.isCoediting(document.uri)) return; const text = document.getText(); for (const proposal of state.artifact.proposals) { const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; diff --git a/src/threadController.ts b/src/threadController.ts index d5b7445..54d3135 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -15,6 +15,7 @@ import { gitUserEmail } from "./identity"; import { addThread, appendMessage, setStatus } from "./threadModel"; import type { VersionGuard } from "./versionGuard"; import { isAuthorable } from "./workspacePath"; +import type { CoeditingRegistry } from "./coeditingRegistry"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedThread { @@ -41,18 +42,24 @@ export class ThreadController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; private readonly docs = new Map(); // keyed by docPath + /** Kept as ONE object; re-assigned on every registry change so VS Code re-queries + * (spec §6.4 v0.2.1 — reassignment 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)]; + }, + }; + constructor( private readonly store: SidecarRouter, private readonly rootDir: string | undefined, private readonly guard: VersionGuard, + private readonly registry: CoeditingRegistry, ) { this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); - this.controller.commentingRangeProvider = { - provideCommentingRanges: (document) => { - if (!isAuthorable(document.uri.scheme)) return []; - return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)]; - }, - }; + this.controller.commentingRangeProvider = this.rangeProvider; this.disposables.push(this.controller); this.disposables.push( @@ -66,6 +73,20 @@ export class ThreadController implements vscode.Disposable { ), vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), + // INV-10 (PUC-7): force VS Code to re-query commenting ranges on every gate + // change, and hide/restore rendered threads — exit hides only (dispose the + // rendered vsThreads), re-enter restores (renderAll reloads from the + // sidecar, which is the durable source of truth regardless of gate state). + this.registry.onDidChange(({ uri, coediting }) => { + this.controller.commentingRangeProvider = this.rangeProvider; + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); + if (!doc) return; + if (coediting) { + this.renderAll(doc); + } else { + this.disposeRendered(this.keyOf(doc)); + } + }), ); } @@ -111,6 +132,7 @@ export class ThreadController implements vscode.Disposable { async createThreadOnSelection(firstBody = "New thread"): Promise { const editor = vscode.window.activeTextEditor; if (!editor || editor.selection.isEmpty || !isAuthorable(editor.document.uri.scheme)) return undefined; + if (!this.registry.isCoediting(editor.document.uri)) return undefined; if (this.guard.isReadOnly(this.keyOf(editor.document))) return undefined; const document = editor.document; const state = this.ensureState(document); @@ -152,6 +174,7 @@ export class ThreadController implements vscode.Disposable { /** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */ renderAll(document: vscode.TextDocument): void { + if (!this.registry.isCoediting(document.uri)) return; const docPath = this.keyOf(document); const state = this.ensureState(document); // fresh artifact from disk (reload / external change) @@ -269,6 +292,17 @@ export class ThreadController implements vscode.Disposable { return undefined; } + /** PUC-7 (INV-10): exit hides only — dispose the rendered vsThreads for a doc + * without touching its sidecar-backed artifact (re-enter restores via renderAll). */ + private disposeRendered(docPath: string): void { + const state = this.docs.get(docPath); + if (!state) return; + for (const vsThread of state.vsThreads.values()) vsThread.dispose(); + state.vsThreads.clear(); + state.live.clear(); + state.orphaned.clear(); + } + // ---- test-facing surface -------------------------------------------------------- getRendered(docPath: string): RenderedThread[] { diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index a6c0c8e..8c2b951 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -19,6 +19,10 @@ import { isAuthorable } from "./workspacePath"; import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; import type { LiveProgressUi } from "./liveProgressUi"; import { promptEditInstruction } from "./editInstructionInput"; +import type { CoeditingRegistry } from "./coeditingRegistry"; + +/** The exact warning copy for every gated edit gesture (INV-10). */ +const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first."; /** * F11: a host edit turn (selection/document text + instruction → rewrite). @@ -83,6 +87,7 @@ export class TrackChangesPreviewController implements vscode.Disposable { private readonly attribution: AttributionController, private readonly proposals: ProposalController, private readonly liveProgressUi: LiveProgressUi, + private readonly registry: CoeditingRegistry, ) { this.disposables.push( // F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri; @@ -247,6 +252,13 @@ export class TrackChangesPreviewController implements vscode.Disposable { * the result as F4 proposal(s). UI wrapper around `runEditAndPropose`. */ private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise { + // INV-10: the choke point for BOTH the editDocument command and the webview's + // askClaude toolbar intent (either scope) — a non-entered doc gets the warning, + // not a turn. + if (!this.registry.isCoediting(document.uri)) { + void vscode.window.showWarningMessage(NOT_COEDITING_WARNING); + return; + } // Both scopes use the same multi-line split-below webview box; only the header // (and the downstream proposal logic) differs. For a selection the document // above keeps the selection highlighted while the box is open. diff --git a/test/e2e/suite/attribution.test.ts b/test/e2e/suite/attribution.test.ts index a1b1010..4dc334a 100644 --- a/test/e2e/suite/attribution.test.ts +++ b/test/e2e/suite/attribution.test.ts @@ -18,6 +18,9 @@ async function openDoc(): Promise { const uri = vscode.Uri.file(path.join(WS, DOC_REL)); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + // INV-10: attribution tracking is gated on coediting (Task 4) — idempotent + // across this order-dependent suite's repeated openDoc() calls. + await vscode.commands.executeCommand("cowriting.coeditDocument"); return doc; } async function getApi(): Promise { diff --git a/test/e2e/suite/authorship.test.ts b/test/e2e/suite/authorship.test.ts index 64d2ec9..300220e 100644 --- a/test/e2e/suite/authorship.test.ts +++ b/test/e2e/suite/authorship.test.ts @@ -34,6 +34,9 @@ suite("F10 review preview — pending Claude proposal renders cw-ins-claude in t const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + // INV-10: entering coediting is required for attribution tracking + F12 + // optimistic-apply to fire — this suite never entered it (pre-migration). + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); const api = await getApi(); const key = uri.toString(); diff --git a/test/e2e/suite/coediting.test.ts b/test/e2e/suite/coediting.test.ts index ff99efe..3c2fe0d 100644 --- a/test/e2e/suite/coediting.test.ts +++ b/test/e2e/suite/coediting.test.ts @@ -32,4 +32,27 @@ suite("coediting registry (PUC-7)", () => { assert.strictEqual(api.coeditingRegistry.isCoediting(doc.uri), false); assert.strictEqual(api.scmSurfaceController.changeCount(doc.uri.toString()), 0); }); + + // Task 4 (INV-10, PUC-7): the no-hijack scenario — a non-entered doc gets no + // thread-creation surface at all; entering unlocks it; exiting hides the + // rendered thread (not the sidecar-backed data); re-entering restores it. + 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); + }); }); diff --git a/test/e2e/suite/crossrung.test.ts b/test/e2e/suite/crossrung.test.ts index aa188e9..9ac7f1c 100644 --- a/test/e2e/suite/crossrung.test.ts +++ b/test/e2e/suite/crossrung.test.ts @@ -17,6 +17,8 @@ async function open(docRel: string): Promise { const uri = vscode.Uri.file(path.join(WS, docRel)); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + // INV-10: thread creation/rendering is gated on coediting (Task 4). + await vscode.commands.executeCommand("cowriting.coeditDocument"); return doc; } async function getApi(): Promise { diff --git a/test/e2e/suite/f12Accept.test.ts b/test/e2e/suite/f12Accept.test.ts index 16a38f0..ae7d769 100644 --- a/test/e2e/suite/f12Accept.test.ts +++ b/test/e2e/suite/f12Accept.test.ts @@ -21,6 +21,9 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + // INV-10: proposal rendering (F12 optimistic-apply, driven by + // onDidChangeProposals) is gated on coediting (Task 4). + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } diff --git a/test/e2e/suite/f12Reach.test.ts b/test/e2e/suite/f12Reach.test.ts index e559073..6aaf940 100644 --- a/test/e2e/suite/f12Reach.test.ts +++ b/test/e2e/suite/f12Reach.test.ts @@ -20,6 +20,12 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo fs.writeFileSync(abs, body, "utf8"); const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); + // INV-10: cowriting.editDocument now warns instead of editing a non-entered + // doc (Task 4) — enter it here (briefly making it active) so the tests below + // exercise the routing/targeting behavior, not the gate. The caller + // re-establishes whichever doc it wants active afterward. + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); return { doc, key: uri.toString() }; } diff --git a/test/e2e/suite/f12Review.test.ts b/test/e2e/suite/f12Review.test.ts index 867cd9f..7a7be35 100644 --- a/test/e2e/suite/f12Review.test.ts +++ b/test/e2e/suite/f12Review.test.ts @@ -20,6 +20,9 @@ async function freshDoc(rel: string, body: string): Promise fs.writeFileSync(abs, body, "utf8"); const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs)); await vscode.window.showTextDocument(doc); + // INV-10: attribution tracking (accept's per-word attribution, INV-40) is + // gated on coediting (Task 4). + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return doc; } diff --git a/test/e2e/suite/liveProgress.test.ts b/test/e2e/suite/liveProgress.test.ts index 1ca5c7c..39dabaf 100644 --- a/test/e2e/suite/liveProgress.test.ts +++ b/test/e2e/suite/liveProgress.test.ts @@ -20,6 +20,9 @@ async function freshDoc(rel: string, body: string): Promise fs.writeFileSync(abs, body, "utf8"); const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs)); await vscode.window.showTextDocument(doc); + // INV-10: proposal rendering (F12 optimistic-apply, driven by + // onDidChangeProposals) is gated on coediting (Task 4). + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return doc; } diff --git a/test/e2e/suite/outOfWorkspace.test.ts b/test/e2e/suite/outOfWorkspace.test.ts index f75de0c..adabf77 100644 --- a/test/e2e/suite/outOfWorkspace.test.ts +++ b/test/e2e/suite/outOfWorkspace.test.ts @@ -37,6 +37,8 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)", const uri = vscode.Uri.file(outsidePath); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + // INV-10: attribution tracking + thread creation are gated on coediting (Task 4). + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); const api = await getApi(); const key = uri.toString(); @@ -80,6 +82,8 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)", const TARGET = "The untitled draft sentence."; const untitled = await vscode.workspace.openTextDocument({ content: `${TARGET}\n`, language: "markdown" }); await vscode.window.showTextDocument(untitled); + // INV-10: attribution tracking is gated on coediting (Task 4). + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); const api = await getApi(); const key = untitled.uri.toString(); diff --git a/test/e2e/suite/proposals.test.ts b/test/e2e/suite/proposals.test.ts index fc6721c..4684b6a 100644 --- a/test/e2e/suite/proposals.test.ts +++ b/test/e2e/suite/proposals.test.ts @@ -18,6 +18,10 @@ async function openDoc(): Promise { const uri = vscode.Uri.file(path.join(WS, DOC_REL)); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + // INV-10: proposal rendering (and the F12 optimistic-apply it drives via + // onDidChangeProposals) is gated on coediting (Task 4) — idempotent across + // this order-dependent suite's repeated openDoc() calls. + await vscode.commands.executeCommand("cowriting.coeditDocument"); return doc; } async function getApi(): Promise { diff --git a/test/e2e/suite/threads.test.ts b/test/e2e/suite/threads.test.ts index 5b43dfe..347c1f8 100644 --- a/test/e2e/suite/threads.test.ts +++ b/test/e2e/suite/threads.test.ts @@ -18,6 +18,9 @@ async function openSample(): Promise { const uri = vscode.Uri.file(path.join(WS, DOC_REL)); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + // INV-10: thread creation/rendering is gated on coediting (Task 4) — idempotent + // across this order-dependent suite's repeated openSample() calls. + await vscode.commands.executeCommand("cowriting.coeditDocument"); return doc; } -- 2.39.5 From a36353d04105d4fac7ccfe69d055d3b289666f4a Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 08:04:36 -0700 Subject: [PATCH 06/18] =?UTF-8?q?fix:=20restore=20proposals=20+=20attribut?= =?UTF-8?q?ion=20on=20coediting=20enter=20(PUC-7)=20=E2=80=94=20registry?= =?UTF-8?q?=20onDidChange=20subscribers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 gated every surface on CoeditingRegistry but only wired ThreadController/EditorProposalController to registry.onDidChange for restore-on-enter — ProposalController.renderAll and AttributionController.loadAll were never called on the enter transition, so a document's FIRST enter with pre-existing sidecar proposals/attribution showed threads but not proposal decorations/CodeLens or committed author-coloring until a later edit happened to fire them (a gap Task 4's own report flagged as known and unfixed). Both controllers now subscribe to registry.onDidChange in their constructors and call renderAll/loadAll on entry only (both already self-gate on isCoediting; exit stays hide-only, mirroring ThreadController). Construction order in extension.ts (attribution before proposals before EditorProposalController) keeps the same-tick read order correct. Co-Authored-By: Claude Fable 5 --- src/attributionController.ts | 15 ++++++ src/proposalController.ts | 17 +++++++ test/e2e/suite/coediting.test.ts | 84 +++++++++++++++++++++++++++++++- 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/attributionController.ts b/src/attributionController.ts index 528a67e..5b96950 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -77,6 +77,21 @@ export class AttributionController implements vscode.Disposable { vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), vscode.window.onDidChangeActiveTextEditor(() => this.renderActive()), + // PUC-7 restore-on-enter: `loadAll` is the only path that (re)populates + // s.spans/s.orphans from the sidecar — without this, a document's FIRST + // entry into coediting (registry.enter(), extension.ts) never runs it, + // so pre-existing committed authorship never surfaces until the next + // edit. Mirrors ThreadController's own registry subscription; `loadAll` + // already gates on isCoediting, and exit intentionally does nothing here + // (no data to wipe — EditorProposalController's own gate clears the + // decorations that read spansFor). Registered before ProposalController/ + // EditorProposalController (construction order in extension.ts) so their + // renders see fresh spans on the same enter transition. + this.registry.onDidChange(({ uri, coediting }) => { + if (!coediting) return; + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); + if (doc) this.loadAll(doc); + }), ); } diff --git a/src/proposalController.ts b/src/proposalController.ts index 36373a5..bbc5177 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -63,6 +63,23 @@ export class ProposalController implements vscode.Disposable { this.disposables.push(this.onDidChangeProposalsEmitter); this.disposables.push( vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), + // PUC-7 restore-on-enter (mirrors ThreadController/AttributionController): + // `renderAll` is the only path that recomputes state.live/unresolved AND + // fires onDidChangeProposals — the event EditorProposalController listens + // to for optimistic-apply (decorations/CodeLens). Without this, a FIRST + // entry into coediting (registry.enter()) never fires it, so pre-existing + // pending proposals stay un-applied/undecorated until the next edit. + // `renderAll` already gates on isCoediting; exit intentionally does + // nothing here (it clears only the live/unresolved recompute, not the + // persisted artifact — EditorProposalController's own gate hides the + // decorations). Registered after AttributionController's own subscription + // (construction order in extension.ts) so decorateCommitted's spansFor + // read sees fresh attribution on the same enter transition. + this.registry.onDidChange(({ uri, coediting }) => { + if (!coediting) return; + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); + if (doc) this.renderAll(doc); + }), ); } diff --git a/test/e2e/suite/coediting.test.ts b/test/e2e/suite/coediting.test.ts index 3c2fe0d..a9b885a 100644 --- a/test/e2e/suite/coediting.test.ts +++ b/test/e2e/suite/coediting.test.ts @@ -1,6 +1,10 @@ import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; import * as vscode from "vscode"; -import { activateApi, settle } from "./helpers"; +import { activateApi, settle, settleUntil } from "./helpers"; + +const WS = process.env.E2E_WORKSPACE!; async function api() { const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; @@ -55,4 +59,82 @@ suite("coediting registry (PUC-7)", () => { await settle(); assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 1); }); + + // PUC-7 restore-on-enter gap: unlike ThreadController (Task 4, above), + // ProposalController.renderAll and AttributionController.loadAll were never + // wired to registry.onDidChange — so a document's FIRST enter into + // coediting with pre-existing sidecar proposals/attribution showed threads + // but not proposal decorations/CodeLens or committed author-coloring until + // a later edit happened to fire renderAll/loadAll. A same-session + // exit-then-re-enter doesn't reproduce this on its own (nothing clears the + // in-memory render caches on exit), so this test wipes those caches after + // exit — simulating "never rendered in this session yet" — then re-enters + // and asserts BOTH surfaces restore via the registry.onDidChange path + // alone, with no subsequent edit. + test("re-enter after exit restores proposal + attribution surfaces without an edit (PUC-7)", async () => { + const api = await activateApi(); + // A workspace FILE (not untitled) — attribution only persists to the + // sidecar on save, and this test needs the sidecar (not just in-memory + // live state) to hold the seeded data before re-entering. + const rel = "docs/puc7-restore.md"; + const abs = path.join(WS, rel); + fs.writeFileSync(abs, "# t\n\nfoo bar baz\n", "utf8"); + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs)); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settle(); + + // Seed a pending proposal (F4) + a committed agent-attributed edit (F3) — + // the two surfaces the gap left un-restored. + const target = "bar"; + const start = doc.getText().indexOf(target); + const proposalId = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { + uri: doc.uri.toString(), + start, + end: start + target.length, + newText: "BAR-PROPOSED", + model: "sonnet", + sessionId: "e2e-puc7", + turnId: "turn-puc7", + }); + assert.ok(proposalId, "propose returns an id"); + // A real text change (not a no-op replace) — "foo" precedes the "bar" + // target above, so it is unaffected by F12's optimistic-apply of the + // proposal into the buffer. + const fooStart = doc.getText().indexOf("foo"); + const applied = await api.attributionController.applyAgentEdit( + doc, + new vscode.Range(doc.positionAt(fooStart), doc.positionAt(fooStart + "foo".length)), + "FOO-CLAUDE", + { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-puc7" } }, + { turnId: "turn-puc7" }, + ); + assert.strictEqual(applied, true, "seam edit applies"); + await settle(); + await doc.save(); // persists attribution records to the sidecar (F3 PUC-4) + + const key = api.proposalController.keyFor(doc); + assert.ok(api.proposalController.getRendered(key).length >= 1, "sanity: proposal rendered pre-exit"); + assert.ok(api.attributionController.getSpans(key).length >= 1, "sanity: attribution rendered pre-exit"); + + await vscode.commands.executeCommand("cowriting.stopCoediting"); + + // Wipe the controllers' in-memory render caches for this doc — nothing in + // production does this on exit (by design: exit hides, it does not wipe + // sidecar-backed state), so this reflection reproduces the "never + // rendered this session" precondition of a genuinely fresh first-enter. + (api.proposalController as unknown as { docs: Map }).docs.delete(key); + (api.attributionController as unknown as { docs: Map }).docs.delete(key); + assert.strictEqual(api.proposalController.getRendered(key).length, 0, "cache cleared pre-re-enter"); + assert.strictEqual(api.attributionController.getSpans(key).length, 0, "cache cleared pre-re-enter"); + + // Re-enter — the registry.onDidChange subscribers (PUC-7 fix) must + // restore both surfaces from the sidecar with no subsequent edit. + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settleUntil( + () => api.proposalController.getRendered(key).length >= 1 && api.attributionController.getSpans(key).length >= 1, + ); + assert.strictEqual(api.proposalController.getRendered(key).length, 1, "proposal restored on re-enter"); + assert.ok(api.attributionController.getSpans(key).length >= 1, "attribution restored on re-enter"); + }); }); -- 2.39.5 From f23fc4afb3aa805c119de05cc1a7309f275ddac8 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 08:07:09 -0700 Subject: [PATCH 07/18] =?UTF-8?q?docs:=20fix=20fused=20Task=205=20heading?= =?UTF-8?q?=20in=20migration=20plan=20(---###=20=E2=86=92=20separate=20lin?= =?UTF-8?q?es)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../superpowers/plans/2026-07-01-native-surfaces-migration.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-01-native-surfaces-migration.md b/docs/superpowers/plans/2026-07-01-native-surfaces-migration.md index df150d9..62d2a7e 100644 --- a/docs/superpowers/plans/2026-07-01-native-surfaces-migration.md +++ b/docs/superpowers/plans/2026-07-01-native-surfaces-migration.md @@ -863,7 +863,9 @@ 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 +--- + +### Task 5: Extract the edit flow from the webview controller **Files:** - Create: `src/editFlow.ts` -- 2.39.5 From ad5d34b85b1687f85b4b4d25f595e2fd69544ec4 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 08:22:44 -0700 Subject: [PATCH 08/18] =?UTF-8?q?refactor:=20extract=20EditFlow=20(runEdit?= =?UTF-8?q?AndPropose=20+=20editDocument)=20from=20the=20review=20webview?= =?UTF-8?q?=20ahead=20of=20its=20sunset=20(spec=20=C2=A76.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves runEditAndPropose, the askClaude gate/prompt/progress wrapper, the EditTarget type, the editTurn/askEditInstruction/turnSeq state, and the cowriting.editDocument command registration out of TrackChangesPreviewController into a new EditFlow (src/editFlow.ts), reachable from the review webview (delegates) and, from Task 6 on, the thread controller. extension.ts now constructs EditFlow before the preview controller and routes acceptAllProposals/rejectAllProposals directly to ProposalController, dropping the preview-controller indirection. E2E suites that drove the old trackChangesPreviewController.{setEditTurnForTest,runEditAndPropose,askEditInstruction} seams now drive the same seams on editFlow. Co-Authored-By: Claude Fable 5 --- src/editFlow.ts | 195 +++++++++++++++++++++++++++ src/extension.ts | 52 ++++--- src/trackChangesPreview.ts | 166 +---------------------- test/e2e/suite/f11Toolbar.test.ts | 24 ++-- test/e2e/suite/f12Accept.test.ts | 17 +-- test/e2e/suite/f12InlineDiff.test.ts | 29 ++-- test/e2e/suite/f12Reach.test.ts | 20 +-- test/e2e/suite/f12Review.test.ts | 24 ++-- test/e2e/suite/liveProgress.test.ts | 12 +- 9 files changed, 300 insertions(+), 239 deletions(-) create mode 100644 src/editFlow.ts diff --git a/src/editFlow.ts b/src/editFlow.ts new file mode 100644 index 0000000..a174fdd --- /dev/null +++ b/src/editFlow.ts @@ -0,0 +1,195 @@ +/** + * EditFlow — the host edit-turn flow (F11/F12), extracted from the review + * webview controller ahead of its sunset (native-surfaces migration, spec + * §6.10). Owns the `cowriting.editDocument` command, the instruction prompt + + * progress-wrapped "ask Claude" gesture (INV-10 gate, INV-8 host-only LLM + * surface), and the low-level turn→proposal(s) cut (`runEditAndPropose`, + * INV-39/40). Never mutates the document directly — every turn lands as one or + * more F4 proposals via `ProposalController.propose` (INV-10). Reachable from + * both the review webview (delegates here) and, from Task 6 on, the thread + * controller — vscode-API-only, no webview state. + */ +import * as vscode from "vscode"; +import type { ProposalController } from "./proposalController"; +import type { AttributionController } from "./attributionController"; +import type { LiveProgressUi } from "./liveProgressUi"; +import type { CoeditingRegistry } from "./coeditingRegistry"; +import { diffToBlockHunks } from "./trackChangesModel"; +import { buildFingerprint } from "./anchorer"; +import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; +import { promptEditInstruction } from "./editInstructionInput"; + +/** The exact warning copy for every gated edit gesture (INV-10). */ +const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first."; + +/** + * F11: a host edit turn (selection/document text + instruction → rewrite). + * Injectable for tests. #60: accepts optional turn options (onProgress/signal); + * the arg is optional so existing test stubs that ignore it stay valid. + */ +type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise; +/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */ +export type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" }; + +export class EditFlow implements vscode.Disposable { + private readonly disposables: vscode.Disposable[] = []; + /** + * F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and + * never bundled). Injectable so host E2E can stub it (no LLM in CI). + */ + private editTurn: EditTurn = async (instruction, text, opts) => { + const { runEditTurn } = await import("./liveTurn"); + return runEditTurn(instruction, text, opts); + }; + /** + * The instruction prompt (the multi-line split-below webview box) for BOTH the + * selection and document cases. A field so host E2E can stub it — the webview + * DOM can't run in CI (mirrors `editTurn`). Also used by the editor's + * `cowriting.editSelection` command (extension.ts). + */ + askEditInstruction: (header: string) => Promise = promptEditInstruction; + /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ + private turnSeq = 0; + private nextTurnSeq(): number { + return ++this.turnSeq; + } + + constructor( + private readonly proposals: ProposalController, + private readonly attribution: AttributionController, + private readonly liveProgressUi: LiveProgressUi, + private readonly registry: CoeditingRegistry, + ) { + this.disposables.push( + // F11: document-scoped Ask-Claude (also reused by #42's reach gateways). + // Edits a markdown doc; the rewrite is diffed into F4 proposals. + // #42 (INV-38): the editor/title/context (tab) entry passes the clicked + // tab's resource Uri — target THAT document, opening it if it isn't already + // an open buffer (mirrors showTrackChangesPreview's #41 resolution); the + // palette / keybinding / editor/context pass nothing → the active editor. + vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => { + const doc = uri + ? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ?? + (await vscode.workspace.openTextDocument(uri)) + : vscode.window.activeTextEditor?.document; + if (!doc || doc.languageId !== "markdown") { + void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it."); + return; + } + void this.askClaude(doc, { kind: "document" }); + }), + ); + } + + /** + * F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret + * surface out of the sealed webview, INV-8/35), run the edit turn, and surface + * the result as F4 proposal(s). UI wrapper around `runEditAndPropose`. Called + * both by the `cowriting.editDocument` command above and (via delegation) by + * the review webview's `askClaude` toolbar intent (either scope). + */ + async askClaude(document: vscode.TextDocument, target: EditTarget): Promise { + // INV-10: the choke point for BOTH the editDocument command and the webview's + // askClaude toolbar intent (either scope) — a non-entered doc gets the warning, + // not a turn. + if (!this.registry.isCoediting(document.uri)) { + void vscode.window.showWarningMessage(NOT_COEDITING_WARNING); + return; + } + // Both scopes use the same multi-line split-below webview box; only the header + // (and the downstream proposal logic) differs. For a selection the document + // above keeps the selection highlighted while the box is open. + const header = + target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection"; + const instruction = await this.askEditInstruction(header); + if (!instruction) return; + 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) { + // #60 (INV-47): a user cancel proposes nothing (the benign empty path). + 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}`); + } + } + + /** + * F11/F12 (INV-35/39): run one host edit turn and record the result as F4 + * proposal(s) — a SELECTION yields one single-range proposal over the resolved + * block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per + * changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged + * `granularity:"block"` so accept reconciles attribution per word (INV-40). + * Never mutates the document (INV-10). Returns the created proposal ids. + */ + async runEditAndPropose( + document: vscode.TextDocument, + target: EditTarget, + instruction: string, + opts?: RunEditTurnOptions, + ): Promise { + const full = document.getText(); + // One turnId per gesture — the document case's N hunk-proposals all share it, + // so a single rewrite groups as one agent turn (parity with editSelection). + const turnId = `turn-${this.nextTurnSeq()}`; + const provenance = (turn: EditTurnResult) => + ({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } }); + if (target.kind === "range") { + const selected = full.slice(target.start, target.end); + const turn = await this.editTurn(instruction, selected, opts); + if (turn.replacement === "" || turn.replacement === selected) return []; + const fp = buildFingerprint(full, { start: target.start, end: target.end }); + const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction }); + return id ? [id] : []; + } + const turn = await this.editTurn(instruction, full, opts); + const ids: string[] = []; + // #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK + // granularity — one proposal per changed block (the unit a human reviews) — + // not per word. Each is tagged `granularity:"block"` so accept reconciles + // attribution per word inside the block (INV-40). + for (const h of diffToBlockHunks(full, turn.replacement)) { + const fp = buildFingerprint(full, { start: h.start, end: h.end }); + const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), { + turnId, + instruction, + granularity: "block", + }); + if (id) ids.push(id); + } + return ids; + } + + // ---- test seam (§6.4) ---- + /** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */ + setEditTurnForTest(fn: EditTurn): void { + this.editTurn = fn; + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + } +} diff --git a/src/extension.ts b/src/extension.ts index cf978a6..4ecba83 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,6 +12,7 @@ import { SidecarRouter } from "./sidecarRouter"; import { DiffViewController } from "./diffViewController"; import { GitBaselineAdapter } from "./gitBaseline"; import { TrackChangesPreviewController } from "./trackChangesPreview"; +import { EditFlow } from "./editFlow"; import { LiveProgressUi } from "./liveProgressUi"; import { EditorProposalController } from "./editorProposalController"; import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath"; @@ -29,6 +30,7 @@ export interface CowritingApi { versionGuard: VersionGuard; diffViewController: DiffViewController; trackChangesPreviewController: TrackChangesPreviewController; + editFlow: EditFlow; sidecarRouter: SidecarRouter; liveProgressUi: LiveProgressUi; editorProposalController: EditorProposalController; @@ -164,17 +166,25 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ); context.subscriptions.push(proposalController); + // --- F11/F12 (native-surfaces migration, spec §6.10): the edit flow — the + // `cowriting.editDocument` command, the instruction-prompt/progress-wrapped + // "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut + // (`runEditAndPropose`, INV-39/40). Constructed BEFORE the review preview (which + // delegates to it) and reachable from the thread controller too (Task 6). --- + const editFlow = new EditFlow(proposalController, attributionController, liveProgressUi, coeditingRegistry); + context.subscriptions.push(editFlow); + // --- F7/F10: the review preview is the single interactive review surface --- // Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline, - // INV-20). Constructed AFTER attribution (reads F3 spans) and proposals (routes - // F4 accept/reject from the webview ✓/✗). + // INV-20). Constructed AFTER attribution (reads F3 spans), proposals (routes + // F4 accept/reject from the webview ✓/✗), and editFlow (delegates the webview's + // askClaude toolbar intent to it). const trackChangesPreviewController = new TrackChangesPreviewController( diffViewController, context.extensionUri, attributionController, proposalController, - liveProgressUi, - coeditingRegistry, + editFlow, ); context.subscriptions.push(trackChangesPreviewController); @@ -189,8 +199,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef context.subscriptions.push(editorProposalController); // #46 (INV-42): accept every pending proposal on the active doc in one gesture - // (also reachable from the preview toolbar's "Accept all" button). Reuses the - // batched F4 seam + reports applied-vs-skipped. + // (also reachable from the preview toolbar's "Accept all" button, which routes + // through its own webview-facing acceptAll). Reuses the batched F4 seam + + // reports applied-vs-skipped (native-surfaces migration: routes directly to + // ProposalController, no preview-controller indirection). context.subscriptions.push( vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => { const doc = vscode.window.activeTextEditor?.document; @@ -198,11 +210,17 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals."); return; } - await trackChangesPreviewController.acceptAll(doc); + const { applied, skipped } = await proposalController.acceptAllProposals(doc); + if (applied === 0 && skipped === 0) return; + const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : ""; + void vscode.window.showInformationMessage( + `Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`, + ); }), ); - // #64 (INV-53): reject every pending proposal on the active doc in one gesture. + // #64 (INV-53): reject every pending proposal on the active doc in one gesture + // (native-surfaces migration: routes directly to ProposalController). context.subscriptions.push( vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => { const doc = vscode.window.activeTextEditor?.document; @@ -210,7 +228,12 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals."); return; } - await trackChangesPreviewController.rejectAll(doc); + const { reverted } = await proposalController.rejectAll(doc); + if (reverted > 0) { + void vscode.window.showInformationMessage( + `Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`, + ); + } }), ); @@ -319,12 +342,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef end: document.offsetAt(selection.end), }); // The instruction prompt is the multi-line split-below webview box (shared - // with the document case, via the preview controller); the document above - // keeps the selection highlighted while it's open. selectedText/fp were - // captured above, so moving focus to the box doesn't affect what we edit. - const instruction = await trackChangesPreviewController.askEditInstruction( - "Ask Claude to Edit This Selection", - ); + // with the document case, via EditFlow); the document above keeps the + // selection highlighted while it's open. selectedText/fp were captured + // above, so moving focus to the box doesn't affect what we edit. + const instruction = await editFlow.askEditInstruction("Ask Claude to Edit This Selection"); if (!instruction) return; const turnId = `turn-${Date.now().toString(36)}`; try { @@ -433,6 +454,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef versionGuard, diffViewController, trackChangesPreviewController, + editFlow, sidecarRouter, liveProgressUi, editorProposalController, diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index 8c2b951..9c1b1e5 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -13,25 +13,9 @@ import * as vscode from "vscode"; import type { DiffViewController } from "./diffViewController"; import type { AttributionController } from "./attributionController"; import type { ProposalController } from "./proposalController"; -import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, landedTextOf, type BlockOp } from "./trackChangesModel"; -import { buildFingerprint } from "./anchorer"; +import { renderReview, renderPlain, diffBlocks, landedTextOf, type BlockOp } from "./trackChangesModel"; import { isAuthorable } from "./workspacePath"; -import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; -import type { LiveProgressUi } from "./liveProgressUi"; -import { promptEditInstruction } from "./editInstructionInput"; -import type { CoeditingRegistry } from "./coeditingRegistry"; - -/** The exact warning copy for every gated edit gesture (INV-10). */ -const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first."; - -/** - * F11: a host edit turn (selection/document text + instruction → rewrite). - * Injectable for tests. #60: accepts optional turn options (onProgress/signal); - * the arg is optional so existing test stubs that ignore it stay valid. - */ -type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise; -/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */ -type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" }; +import type { EditFlow, EditTarget } from "./editFlow"; const VIEW_TYPE = "cowriting.trackChangesPreview"; const DEBOUNCE_MS = 150; @@ -60,34 +44,13 @@ export class TrackChangesPreviewController implements vscode.Disposable { private readonly mode = new Map(); /** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */ private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88); - /** - * F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and - * never bundled). Injectable so host E2E can stub it (no LLM in CI). - */ - private editTurn: EditTurn = async (instruction, text, opts) => { - const { runEditTurn } = await import("./liveTurn"); - return runEditTurn(instruction, text, opts); - }; - /** - * The instruction prompt (the multi-line split-below webview box) for BOTH the - * selection and document cases. A field so host E2E can stub it — the webview - * DOM can't run in CI (mirrors `editTurn`). Also used by the editor's - * `cowriting.editSelection` command (via this controller). - */ - askEditInstruction: (header: string) => Promise = promptEditInstruction; - /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ - private turnSeq = 0; - private nextTurnSeq(): number { - return ++this.turnSeq; - } constructor( private readonly diffView: DiffViewController, private readonly extensionUri: vscode.Uri, private readonly attribution: AttributionController, private readonly proposals: ProposalController, - private readonly liveProgressUi: LiveProgressUi, - private readonly registry: CoeditingRegistry, + private readonly editFlow: EditFlow, ) { this.disposables.push( // F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri; @@ -103,23 +66,6 @@ export class TrackChangesPreviewController implements vscode.Disposable { } this.show(vscode.window.activeTextEditor?.document); }), - // F11: document-scoped Ask-Claude (also reused by #42's reach gateways). - // Edits a markdown doc; the rewrite is diffed into F4 proposals. - // #42 (INV-38): the editor/title/context (tab) entry passes the clicked - // tab's resource Uri — target THAT document, opening it if it isn't already - // an open buffer (mirrors showTrackChangesPreview's #41 resolution); the - // palette / keybinding / editor/context pass nothing → the active editor. - vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => { - const doc = uri - ? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ?? - (await vscode.workspace.openTextDocument(uri)) - : vscode.window.activeTextEditor?.document; - if (!doc || !this.isMarkdown(doc)) { - void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it."); - return; - } - void this.askClaude(doc, { kind: "document" }); - }), vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)), this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)), this.proposals.onDidChangeProposals(({ uri }) => { @@ -209,7 +155,7 @@ export class TrackChangesPreviewController implements vscode.Disposable { } else if (m?.type === "askClaude") { const target: EditTarget = m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" }; - void this.askClaude(document, target); + void this.editFlow.askClaude(document, target); } else if (m?.type === "acceptAll") { // #46 (INV-42): batch-accept every pending proposal on this doc, then report. void this.acceptAll(document); @@ -246,106 +192,6 @@ export class TrackChangesPreviewController implements vscode.Disposable { } } - /** - * F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret - * surface out of the sealed webview, INV-8/35), run the edit turn, and surface - * the result as F4 proposal(s). UI wrapper around `runEditAndPropose`. - */ - private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise { - // INV-10: the choke point for BOTH the editDocument command and the webview's - // askClaude toolbar intent (either scope) — a non-entered doc gets the warning, - // not a turn. - if (!this.registry.isCoediting(document.uri)) { - void vscode.window.showWarningMessage(NOT_COEDITING_WARNING); - return; - } - // Both scopes use the same multi-line split-below webview box; only the header - // (and the downstream proposal logic) differs. For a selection the document - // above keeps the selection highlighted while the box is open. - const header = - target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection"; - const instruction = await this.askEditInstruction(header); - if (!instruction) return; - 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) { - // #60 (INV-47): a user cancel proposes nothing (the benign empty path). - 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}`); - } - } - - /** - * F11/F12 (INV-35/39): run one host edit turn and record the result as F4 - * proposal(s) — a SELECTION yields one single-range proposal over the resolved - * block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per - * changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged - * `granularity:"block"` so accept reconciles attribution per word (INV-40). - * Never mutates the document (INV-10). Returns the created proposal ids. - */ - async runEditAndPropose( - document: vscode.TextDocument, - target: EditTarget, - instruction: string, - opts?: RunEditTurnOptions, - ): Promise { - const full = document.getText(); - // One turnId per gesture — the document case's N hunk-proposals all share it, - // so a single rewrite groups as one agent turn (parity with editSelection). - const turnId = `turn-${this.nextTurnSeq()}`; - const provenance = (turn: EditTurnResult) => - ({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } }); - if (target.kind === "range") { - const selected = full.slice(target.start, target.end); - const turn = await this.editTurn(instruction, selected, opts); - if (turn.replacement === "" || turn.replacement === selected) return []; - const fp = buildFingerprint(full, { start: target.start, end: target.end }); - const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction }); - return id ? [id] : []; - } - const turn = await this.editTurn(instruction, full, opts); - const ids: string[] = []; - // #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK - // granularity — one proposal per changed block (the unit a human reviews) — - // not per word. Each is tagged `granularity:"block"` so accept reconciles - // attribution per word inside the block (INV-40). - for (const h of diffToBlockHunks(full, turn.replacement)) { - const fp = buildFingerprint(full, { start: h.start, end: h.end }); - const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), { - turnId, - instruction, - granularity: "block", - }); - if (id) ids.push(id); - } - return ids; - } - private onEdit(document: vscode.TextDocument): void { const key = document.uri.toString(); if (!this.panels.has(key)) return; @@ -496,10 +342,6 @@ export class TrackChangesPreviewController implements vscode.Disposable { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m); } - /** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */ - setEditTurnForTest(fn: EditTurn): void { - this.editTurn = fn; - } /** * F11 (PUC-1/7): whether the previewed doc's edit controls (Pin + Ask-Claude) * are enabled — true only for an authorable doc. The annotations toggle is diff --git a/test/e2e/suite/f11Toolbar.test.ts b/test/e2e/suite/f11Toolbar.test.ts index ed5f01b..44ddfb0 100644 --- a/test/e2e/suite/f11Toolbar.test.ts +++ b/test/e2e/suite/f11Toolbar.test.ts @@ -80,18 +80,18 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () = "# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n", ); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); // Stub the host edit turn (no LLM in CI): rewrite two distinct words. - ctl.setEditTurnForTest(async () => ({ + editFlow.setEditTurnForTest(async () => ({ replacement: "# F11 doc\n\nThe quick RED fox jumps over the lazy CAT.\n", model: "sonnet", sessionId: "e2e-f11-doc", })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT"); await settle(); assert.strictEqual(ids.length, 1, "two changed words in one block → ONE block proposal (INV-39)"); @@ -114,19 +114,19 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () = const body = "# F11 sel\n\nThe target paragraph Claude will rewrite.\n\nAnother untouched paragraph.\n"; const { doc, key } = await freshDoc("docs/f11sel.md", body); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); const target = "The target paragraph Claude will rewrite."; const start = doc.getText().indexOf(target); const end = start + target.length; - ctl.setEditTurnForTest(async (_instruction, text) => { + editFlow.setEditTurnForTest(async (_instruction, text) => { assert.strictEqual(text, target, "the turn receives exactly the selected source range"); return { replacement: "The REWRITTEN paragraph from Claude.", model: "sonnet", sessionId: "e2e-f11-sel" }; }); - const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph"); await settle(); assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal"); const views = api.proposalController.listProposals(doc); @@ -144,13 +144,13 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () = test("runEditAndPropose(range) where Claude returns the selection unchanged → no proposal", async () => { const { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); const target = "Leave me exactly as I am."; const start = doc.getText().indexOf(target); - ctl.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change"); + editFlow.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change"); assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing"); }); @@ -162,12 +162,12 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () = const rewrite = "# F11 accept\n\nThe brown fox QUIETLY sleeps today.\n"; const { doc } = await freshDoc("docs/f11accept.md", original); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); - ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "expand the sentence"); + editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "expand the sentence"); await settle(); assert.ok(ids.length >= 1, "the rewrite produced at least one proposal"); diff --git a/test/e2e/suite/f12Accept.test.ts b/test/e2e/suite/f12Accept.test.ts index ae7d769..8cca13e 100644 --- a/test/e2e/suite/f12Accept.test.ts +++ b/test/e2e/suite/f12Accept.test.ts @@ -39,11 +39,12 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => { const { doc, key } = await freshDoc("docs/f12-all.md", original); const api = await getApi(); const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); - ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns"); + editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns"); await settle(); assert.strictEqual(ids.length, 3, "three changed blocks → three pending proposals"); @@ -63,12 +64,12 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => { const rewrite = "# Mix\n\nKeep ALPHA here.\n\nKeep GAMMA here.\n"; const { doc } = await freshDoc("docs/f12-orphan.md", original); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); - ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase"); + editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase"); await settle(); assert.strictEqual(ids.length, 2, "two pending proposals"); @@ -100,15 +101,15 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => { test("acceptAllProposals with one pending proposal applies it", async () => { const { doc } = await freshDoc("docs/f12-one.md", "# One\n\nThe only paragraph here.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); - ctl.setEditTurnForTest(async () => ({ + editFlow.setEditTurnForTest(async () => ({ replacement: "# One\n\nThe ONLY paragraph here.\n", model: "sonnet", sessionId: "e2e-f12-one", })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase only"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase only"); await settle(); assert.strictEqual(ids.length, 1, "one pending proposal"); const { applied, skipped } = await api.proposalController.acceptAllProposals(doc); diff --git a/test/e2e/suite/f12InlineDiff.test.ts b/test/e2e/suite/f12InlineDiff.test.ts index 07ae483..5c42855 100644 --- a/test/e2e/suite/f12InlineDiff.test.ts +++ b/test/e2e/suite/f12InlineDiff.test.ts @@ -106,10 +106,10 @@ suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => { test("rejectAll reverts every pending proposal", async () => { const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; const p = api.proposalController; - ctl.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); + 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(); @@ -126,9 +126,9 @@ suite("F12 inline diff — INV-50 listProposals.replaced", () => { test("listProposals reports the original as `replaced` after optimistic apply", async () => { const { doc } = await freshDoc("docs/f12-replaced.md", "# R\n\nbrown here.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; - ctl.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" })); - await ctl.runEditAndPropose(doc, { kind: "document" }, "x"); + const editFlow = api.editFlow; + editFlow.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" })); + await editFlow.runEditAndPropose(doc, { kind: "document" }, "x"); await settle(); await settle(); assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here."); }); @@ -138,9 +138,9 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => { test("proposing optimistically applies into the editor and the buffer is the accepted result", async () => { const { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; - ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" })); - await ctl.runEditAndPropose(doc, { kind: "document" }, "recolor the fox"); + const editFlow = api.editFlow; + editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" })); + await editFlow.runEditAndPropose(doc, { kind: "document" }, "recolor the fox"); await settle(); await settle(); assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer"); const v = api.proposalController.listProposals(doc)[0]; @@ -150,9 +150,9 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => { test("editing the inserted text then finalizing keeps the human edit", async () => { const { doc } = await freshDoc("docs/f12-edit-keep.md", "# E\n\nalpha word here.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; - ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); + const editFlow = api.editFlow; + editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up"); await settle(); await settle(); // human tweaks the inserted text const at = doc.getText().indexOf("ALPHA"); @@ -175,10 +175,11 @@ suite("F12 inline diff — control parity (#64, INV-53)", () => { const { doc, key } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n"); const api = await getApi(); const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); - ctl.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); + editFlow.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up"); await settle(); await settle(); assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB")); // reject ONE via the webview intent → that block reverts, the other stays applied diff --git a/test/e2e/suite/f12Reach.test.ts b/test/e2e/suite/f12Reach.test.ts index 6aaf940..4aa4468 100644 --- a/test/e2e/suite/f12Reach.test.ts +++ b/test/e2e/suite/f12Reach.test.ts @@ -74,7 +74,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { // not whatever editor happens to be active (mirrors #41's clicked-doc resolution). test("editDocument(uri) targets the clicked tab's document, not the active editor", async () => { const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; // Doc A is the active editor; Doc B is the "clicked tab" we pass by URI. const a = await freshDoc("docs/f12-active.md", "# Active\n\nThe active editor paragraph.\n"); @@ -83,9 +83,9 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { await settle(); // Stub the document instruction prompt (the webview can't run in CI) + the LLM turn. - const origPrompt = ctl.askEditInstruction; - ctl.askEditInstruction = async () => "rewrite it"; - ctl.setEditTurnForTest(async () => ({ + const origPrompt = editFlow.askEditInstruction; + editFlow.askEditInstruction = async () => "rewrite it"; + editFlow.setEditTurnForTest(async () => ({ replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n", model: "sonnet", sessionId: "e2e-f12-tab", @@ -94,7 +94,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri); await settle(); } finally { - ctl.askEditInstruction = origPrompt; + editFlow.askEditInstruction = origPrompt; } // The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none. @@ -113,14 +113,14 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { // No URI arg (palette / keybinding) → fall back to the active editor. test("editDocument() with no arg targets the active editor", async () => { const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; const a = await freshDoc("docs/f12-noarg.md", "# No arg\n\nThe active doc paragraph here.\n"); await vscode.window.showTextDocument(a.doc); await settle(); - const origPrompt = ctl.askEditInstruction; - ctl.askEditInstruction = async () => "rewrite it"; - ctl.setEditTurnForTest(async () => ({ + const origPrompt = editFlow.askEditInstruction; + editFlow.askEditInstruction = async () => "rewrite it"; + editFlow.setEditTurnForTest(async () => ({ replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n", model: "sonnet", sessionId: "e2e-f12-noarg", @@ -129,7 +129,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { await vscode.commands.executeCommand("cowriting.editDocument"); await settle(); } finally { - ctl.askEditInstruction = origPrompt; + editFlow.askEditInstruction = origPrompt; } assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg"); }); diff --git a/test/e2e/suite/f12Review.test.ts b/test/e2e/suite/f12Review.test.ts index 7a7be35..bad0887 100644 --- a/test/e2e/suite/f12Review.test.ts +++ b/test/e2e/suite/f12Review.test.ts @@ -39,13 +39,13 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)" "# Doc\n\nFirst paragraph alpha.\n\nSecond paragraph beta.\n\nThird paragraph gamma.\n", ); const api = await getApi(); - const ctl = api.trackChangesPreviewController; - ctl.setEditTurnForTest(async () => ({ + const editFlow = api.editFlow; + editFlow.setEditTurnForTest(async () => ({ replacement: "# Doc\n\nFirst paragraph ALPHA.\n\nSecond paragraph beta.\n\nThird paragraph GAMMA.\n", model: "sonnet", sessionId: "e2e-f12-multi", })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns"); await settle(); assert.strictEqual(ids.length, 2, "two changed blocks → two proposals; the unchanged middle block → none"); @@ -62,13 +62,13 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)" "# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n", ); const api = await getApi(); - const ctl = api.trackChangesPreviewController; - ctl.setEditTurnForTest(async () => ({ + const editFlow = api.editFlow; + editFlow.setEditTurnForTest(async () => ({ replacement: "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n", model: "sonnet", sessionId: "e2e-f12-fence", })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "bump a to 10"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "bump a to 10"); await settle(); assert.strictEqual(ids.length, 1, "a changed fence is one atomic proposal"); const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0])!; @@ -80,15 +80,15 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)" test("accepting a block proposal attributes only the changed words to Claude (INV-40)", async () => { const doc = await freshDoc("docs/f12-attr.md", "# T\n\nThe quick brown fox jumps lazily.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; const key = api.proposalController.keyFor(doc); - ctl.setEditTurnForTest(async () => ({ + editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nThe quick RED fox jumps SLOWLY.\n", model: "sonnet", sessionId: "e2e-f12-attr", })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "change two words"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "change two words"); await settle(); assert.strictEqual(ids.length, 1, "one changed paragraph → one block proposal"); @@ -118,11 +118,11 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)" const rewrite = "# Ins\n\nAlpha block.\n\nBrand new middle block.\n\nBeta block.\n"; const doc = await freshDoc("docs/f12-insert.md", original); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; const key = api.proposalController.keyFor(doc); - ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" })); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph"); + editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" })); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph"); await settle(); assert.ok(ids.length >= 1, "the insertion produced at least one proposal"); diff --git a/test/e2e/suite/liveProgress.test.ts b/test/e2e/suite/liveProgress.test.ts index 39dabaf..6e06b28 100644 --- a/test/e2e/suite/liveProgress.test.ts +++ b/test/e2e/suite/liveProgress.test.ts @@ -36,19 +36,19 @@ suite("#60 live turn progress (additive + cancel)", () => { test("a stub that emits progress still produces the same proposals (INV-44)", async () => { const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); // The stub honors opts.onProgress (emitting synthetic snapshots) but returns // the same rewrite — proposals must be unaffected by progress events. - ctl.setEditTurnForTest(async (_i, _text, opts) => { + editFlow.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-live60" }; }); - const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph"); + const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph"); await settle(); assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events"); const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]); @@ -62,14 +62,14 @@ suite("#60 live turn progress (additive + cancel)", () => { test("an aborted turn proposes nothing (INV-47)", async () => { const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n"); const api = await getApi(); - const ctl = api.trackChangesPreviewController; + const editFlow = api.editFlow; await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); // The stub throws ONLY when the aborted signal reached it — so if opts.signal // failed to thread through runEditAndPropose, the stub would instead return a // rewrite and create a proposal, failing this test. That proves propagation. - ctl.setEditTurnForTest(async (_i, _text, opts) => { + editFlow.setEditTurnForTest(async (_i, _text, opts) => { if (opts?.signal?.aborted) throw new Error("claude-code turn aborted"); return { replacement: "# Title\n\nSHOULD NOT BE PROPOSED.\n", model: "sonnet", sessionId: "e2e-live60-nope" }; }); @@ -78,7 +78,7 @@ suite("#60 live turn progress (additive + cancel)", () => { ac.abort(); let ids: string[] = []; try { - ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal }); + ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal }); } catch { ids = []; } -- 2.39.5 From 8fbbe451eae96f13f7a83f3791e532c513acd2bd Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 08:30:58 -0700 Subject: [PATCH 09/18] fix: repaint open review panel after palette accept/reject-all (restores dropped refresh) Co-Authored-By: Claude Fable 5 --- src/extension.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index 4ecba83..7456457 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -211,6 +211,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef return; } const { applied, skipped } = await proposalController.acceptAllProposals(doc); + trackChangesPreviewController.refresh(doc); if (applied === 0 && skipped === 0) return; const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : ""; void vscode.window.showInformationMessage( @@ -229,6 +230,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef return; } const { reverted } = await proposalController.rejectAll(doc); + trackChangesPreviewController.refresh(doc); if (reverted > 0) { void vscode.window.showInformationMessage( `Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`, -- 2.39.5 From 75f4a3cc200c1da318eb66621fdea27501a96f1f Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 08:48:20 -0700 Subject: [PATCH 10/18] =?UTF-8?q?feat:=20comments-first=20ask=20+=20commen?= =?UTF-8?q?t=E2=86=92reply=E2=86=92offer=E2=86=92proposal=20loop=20(D19/D1?= =?UTF-8?q?0/D8,=20PUC-8);=20sunset=20the=20input=20webview=20(spec=20?= =?UTF-8?q?=C2=A76.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- package.json | 37 ++++- src/editFlow.ts | 19 ++- src/editInstructionInput.ts | 147 -------------------- src/extension.ts | 124 +++-------------- src/threadController.ts | 186 +++++++++++++++++++++++++- test/e2e/suite/coediting.test.ts | 6 + test/e2e/suite/commentLoop.test.ts | 47 +++++++ test/e2e/suite/crossrung.test.ts | 7 + test/e2e/suite/outOfWorkspace.test.ts | 6 + test/e2e/suite/threads.test.ts | 13 ++ 10 files changed, 324 insertions(+), 268 deletions(-) delete mode 100644 src/editInstructionInput.ts create mode 100644 test/e2e/suite/commentLoop.test.ts diff --git a/package.json b/package.json index bd15bcf..1b1bbd2 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,18 @@ "command": "cowriting.stopCoediting", "title": "Stop editing with Claude", "category": "Cowriting" + }, + { + "command": "cowriting.askClaude", + "title": "Ask Claude", + "category": "Cowriting", + "icon": "$(sparkle)" + }, + { + "command": "cowriting.makeThreadEdit", + "title": "✦ Make this edit", + "category": "Cowriting", + "icon": "$(sparkle)" } ], "menus": { @@ -171,6 +183,14 @@ "command": "cowriting.editDocument", "when": "false" }, + { + "command": "cowriting.askClaude", + "when": "editorLangId == markdown && cowriting.isCoediting" + }, + { + "command": "cowriting.makeThreadEdit", + "when": "false" + }, { "command": "cowriting.acceptAllProposals", "when": "editorLangId == markdown && cowriting.isCoediting" @@ -194,6 +214,11 @@ "when": "resourceLangId == markdown && cowriting.isCoediting", "group": "navigation@1" }, + { + "command": "cowriting.askClaude", + "when": "resourceLangId == markdown && cowriting.isCoediting", + "group": "navigation@2" + }, { "command": "cowriting.markReviewed", "when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot", @@ -249,11 +274,21 @@ "comments/commentThread/context": [ { "command": "cowriting.reply", - "group": "inline", + "group": "inline@1", "when": "commentController == cowriting.threads" + }, + { + "command": "cowriting.makeThreadEdit", + "group": "inline@2", + "when": "commentController == cowriting.threads && commentThread == offer" } ], "comments/commentThread/title": [ + { + "command": "cowriting.makeThreadEdit", + "group": "inline@1", + "when": "commentController == cowriting.threads && commentThread == offer" + }, { "command": "cowriting.resolveThread", "group": "inline", diff --git a/src/editFlow.ts b/src/editFlow.ts index a174fdd..1cf440d 100644 --- a/src/editFlow.ts +++ b/src/editFlow.ts @@ -17,7 +17,6 @@ import type { CoeditingRegistry } from "./coeditingRegistry"; import { diffToBlockHunks } from "./trackChangesModel"; import { buildFingerprint } from "./anchorer"; import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; -import { promptEditInstruction } from "./editInstructionInput"; /** The exact warning copy for every gated edit gesture (INV-10). */ const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first."; @@ -42,12 +41,20 @@ export class EditFlow implements vscode.Disposable { return runEditTurn(instruction, text, opts); }; /** - * The instruction prompt (the multi-line split-below webview box) for BOTH the - * selection and document cases. A field so host E2E can stub it — the webview - * DOM can't run in CI (mirrors `editTurn`). Also used by the editor's - * `cowriting.editSelection` command (extension.ts). + * LEGACY (Task 6, spec §6.10): the instruction prompt used to be the + * multi-line split-below input webview (`editInstructionInput.ts`, deleted). + * The real interactive ask is now ThreadController.askClaude — a focused + * comment box (D19); `cowriting.editSelection` routes there directly. This + * field's only remaining production caller is the `cowriting.editDocument` + * command below (via `askClaude`), and the review-webview's toolbar ask — + * both die fully in Task 8. Rejects by default so any surviving real call + * fails loudly instead of silently invoking a webview that no longer + * exists; host E2E stub this field directly (mirrors `editTurn`). */ - askEditInstruction: (header: string) => Promise = promptEditInstruction; + askEditInstruction: (header: string) => Promise = (header) => + Promise.reject( + new Error(`Cowriting: the "${header}" instruction input was removed — use ✦ Ask Claude instead.`), + ); /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ private turnSeq = 0; private nextTurnSeq(): number { diff --git a/src/editInstructionInput.ts b/src/editInstructionInput.ts deleted file mode 100644 index 12cb29c..0000000 --- a/src/editInstructionInput.ts +++ /dev/null @@ -1,147 +0,0 @@ -import * as vscode from "vscode"; -import { randomBytes } from "crypto"; - -/** - * The multi-line instruction input for "Ask Claude to Edit" — BOTH the selection - * and the whole-document case. A small focused webview (a tall, resizable - * textarea + Send) opened in a split pane BELOW the document — not a comment - * thread, and not the top QuickInput (which is single-line only; VS Code has no - * multi-line input at the command-palette location). For a selection edit the - * document stays in the pane above with the selection still highlighted (VS - * Code's inactive-selection style), so the user can see exactly what Claude will - * edit; the caller has already captured the selection, so we never touch it. - * - * The split collapses when the input is submitted or cancelled, and focus is - * handed back to the document so the collapsing split doesn't reveal the bottom - * panel. `header` names the scope ("Ask Claude to Edit This Selection" / - * "…This Document"). - * - * The webview only collects text and posts it to the host — no SDK or secret - * surface lives in it (INV-8/35); the sealed CSP allows no network. Because a - * webview CAN read its own textarea, Cancel / Escape confirms ONLY when there is - * text to lose (closing the tab is an explicit dismiss, no prompt). Resolves with - * the typed instruction, or `undefined` if cancelled / closed / left empty. - */ -export async function promptEditInstruction(header: string): Promise { - // Remember the document editor we came from so we can hand focus back to it - // when the input closes — otherwise, when the empty split group below collapses, - // focus falls into the bottom panel (Output / Debug Console / …) and it pops - // open. Restoring editor focus leaves whatever panel state the user had untouched. - const source = vscode.window.activeTextEditor; - // Open a split editor group BELOW the current one and host the input there, so - // it sits under the document instead of covering it as a tab. The new group is - // empty, so disposing the panel on submit/cancel leaves it empty and VS Code - // collapses the split (the `workbench.editor.closeEmptyGroups` default). Falls - // back to a tab in the active group if the split command is unavailable. - try { - await vscode.commands.executeCommand("workbench.action.newGroupBelow"); - } catch { - /* no split — the panel opens as a tab in the active group instead */ - } - return new Promise((resolve) => { - const panel = vscode.window.createWebviewPanel( - "cowriting.askClaudeInput", - header, - { viewColumn: vscode.ViewColumn.Active, preserveFocus: false }, - { enableScripts: true, retainContextWhenHidden: false }, - ); - - let settled = false; - const done = (value: string | undefined): void => { - if (settled) return; - settled = true; - resolve(value); - panel.dispose(); - // Hand focus back to the originating document so the collapsing split - // doesn't leave focus in (and reveal) the bottom panel. - if (source) { - void vscode.window.showTextDocument(source.document, { - viewColumn: source.viewColumn ?? vscode.ViewColumn.One, - preserveFocus: false, - }); - } - }; - - panel.webview.onDidReceiveMessage((m: { type?: string; text?: string }) => { - const text = (m?.text ?? "").trim(); - if (m?.type === "submit") { - done(text ? text : undefined); - } else if (m?.type === "cancel") { - // Confirm only if there's something to lose (we can read the textarea here). - if (!text) { - done(undefined); - return; - } - void vscode.window - .showWarningMessage("Discard your Ask-Claude instruction?", { modal: true }, "Discard") - .then((pick) => { - if (pick === "Discard") done(undefined); - // else: leave the panel open so the operator can keep editing. - }); - } - }); - // Closing the tab is an explicit dismiss — cancel without a prompt. - panel.onDidDispose(() => done(undefined)); - panel.webview.html = htmlFor(header); - }); -} - -function escapeHtml(s: string): string { - return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); -} - -function htmlFor(header: string): string { - const nonce = randomBytes(16).toString("base64"); - // Sealed CSP: no network; inline style only; the one script is nonce-gated. - const csp = `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';`; - const title = escapeHtml(header); - return ` - - - - - ${title} - - - -

${title}

- -
- ⌘↵ / Ctrl+↵ to send · Esc to cancel - -
- - -`; -} diff --git a/src/extension.ts b/src/extension.ts index 7456457..d39c3a7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,13 +15,11 @@ import { TrackChangesPreviewController } from "./trackChangesPreview"; import { EditFlow } from "./editFlow"; import { LiveProgressUi } from "./liveProgressUi"; import { EditorProposalController } from "./editorProposalController"; -import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath"; +import { isAuthorable, routeEdit } from "./workspacePath"; import { CoeditingRegistry } from "./coeditingRegistry"; import { ScmSurfaceController } from "./scmSurface"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; -/** The exact warning copy for every gated edit gesture (INV-10). */ -const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first."; export interface CowritingApi { threadController: ThreadController; @@ -148,8 +146,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef // F5 (INV-16): one shared guard — newer-major sidecars are read-only, one // warning per doc across the three co-owning controllers. const versionGuard = new VersionGuard(sidecarRouter); - const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry); - context.subscriptions.push(threadController); // --- F3: live attribution (Feature #6) --- const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry); @@ -170,10 +166,17 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef // `cowriting.editDocument` command, the instruction-prompt/progress-wrapped // "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut // (`runEditAndPropose`, INV-39/40). Constructed BEFORE the review preview (which - // delegates to it) and reachable from the thread controller too (Task 6). --- + // delegates to it) and BEFORE the thread controller, which now consumes it too + // (Task 6: the comment-loop's "make this edit" offer calls runEditAndPropose). --- const editFlow = new EditFlow(proposalController, attributionController, liveProgressUi, coeditingRegistry); context.subscriptions.push(editFlow); + // --- F2/F10 (Task 6, D19/D10/D8): comments-first ask + the comment→reply→ + // offer→proposal loop. Consumes editFlow (offer → pending proposals) and + // liveProgressUi (the shared "asking Claude…" notification + output channel). --- + const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry, editFlow, liveProgressUi); + context.subscriptions.push(threadController); + // --- F7/F10: the review preview is the single interactive review surface --- // Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline, // INV-20). Constructed AFTER attribution (reads F3 spans), proposals (routes @@ -307,109 +310,16 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ), ); - // F3 SLICE-5 → F4 SLICE-4: the live turn — selection + instruction → one - // claude-code SDK turn (liveTurn.ts, INV-8) → a PENDING PROPOSAL (F4, - // INV-10); the applyAgentEdit seam (INV-9) now fires only on accept. + // Task 6 (D19): the comments-first ask replaces the old inline turn + // plumbing here — the ask now IS a comment. threadController.askClaude() + // opens a focused comment box on the current selection (or, with no + // selection, a top-anchored whole-document thread); the comment→reply→ + // offer→proposal loop (ThreadController.respondInThread/makeThreadEdit) + // takes it from there. Kept as its own command (rather than folded into + // cowriting.edit) for the host E2E harness and the internal seams. context.subscriptions.push( vscode.commands.registerCommand("cowriting.editSelection", async () => { - const editor = vscode.window.activeTextEditor; - // F8: authoring works on any file: or untitled: doc (the router decides - // where its artifact is stored). Each failure still names its real reason - // (no editor / no selection / a non-{file,untitled} read-only view) — not - // always "select some text" (#24's per-condition messaging). - const reason = selectionRejection({ - hasEditor: !!editor, - selectionEmpty: editor?.selection.isEmpty ?? true, - scheme: editor?.document.uri.scheme ?? "", - }); - if (reason) { - void vscode.window.showWarningMessage(reason); - return; - } - if (!editor) return; // unreachable once reason is null, but narrows the type - const document = editor.document; - // INV-10: a non-entered doc gets the gate warning, not a turn. - if (!coeditingRegistry.isCoediting(document.uri)) { - void vscode.window.showWarningMessage(NOT_COEDITING_WARNING); - return; - } - const selection = editor.selection; // non-empty (selectionRejection guaranteed it) - // Capture the selection text + anchor BEFORE prompting — the inline prompt - // moves the cursor to the document top (where its box anchors), which would - // otherwise collapse the selection we act on (spec §6.5 PUC-1: anchor - // captured pre-turn so mid-turn edits can't skew it). - const selectedText = document.getText(selection); - const fp = buildFingerprint(document.getText(), { - start: document.offsetAt(selection.start), - end: document.offsetAt(selection.end), - }); - // The instruction prompt is the multi-line split-below webview box (shared - // with the document case, via EditFlow); the document above keeps the - // selection highlighted while it's open. selectedText/fp were captured - // above, so moving focus to the box doesn't affect what we edit. - const instruction = await editFlow.askEditInstruction("Ask Claude to Edit This Selection"); - if (!instruction) return; - const turnId = `turn-${Date.now().toString(36)}`; - 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); - let turn; - try { - turn = await runEditTurn(instruction, selectedText, { - onProgress: ui.onProgress, - signal: ui.signal, - }); - } catch (err) { - // #60 (INV-47): a user cancel surfaces as "cancelled", not a failure. - if (token.isCancellationRequested) { - void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled."); - return; - } - throw err; - } - 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; - } - // F4 (INV-10): the turn ends in a PROPOSAL, not a buffer mutation. - // The seam now fires only on accept (ProposalController, INV-9). - 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) { - const message = err instanceof Error ? err.message : String(err); - void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`); - } + await threadController.askClaude(); }), ); diff --git a/src/threadController.ts b/src/threadController.ts index 54d3135..a329bbc 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -16,6 +16,9 @@ import { addThread, appendMessage, setStatus } from "./threadModel"; import type { VersionGuard } from "./versionGuard"; import { isAuthorable } from "./workspacePath"; import type { CoeditingRegistry } from "./coeditingRegistry"; +import type { EditFlow, EditTarget } from "./editFlow"; +import type { LiveProgressUi } from "./liveProgressUi"; +import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedThread { @@ -25,6 +28,15 @@ export interface RenderedThread { range: { startLine: number; endLine: number }; } +/** D10/PUC-8: every human comment on a coedited doc runs this reply turn. */ +const REPLY_PROMPT = (ask: string): string => + "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; + +/** F6.x/Task 6: the injectable low-level turn runner behind respondInThread. */ +type TurnRunner = (instruction: string, context: string, opts?: RunEditTurnOptions) => Promise; + interface DocState { docPath: string; uri: vscode.Uri; @@ -41,6 +53,10 @@ export class ThreadController implements vscode.Disposable { private readonly controller: vscode.CommentController; private readonly disposables: vscode.Disposable[] = []; private readonly docs = new Map(); // keyed by docPath + /** thread -> the pending machine "offer" (D8): the ask it answered, ready to become an edit. */ + private readonly offers = new WeakMap(); + /** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */ + private turnRunner: TurnRunner | undefined; /** Kept as ONE object; re-assigned on every registry change so VS Code re-queries * (spec §6.4 v0.2.1 — reassignment is the API's only "ranges changed" signal). */ @@ -57,6 +73,8 @@ export class ThreadController implements vscode.Disposable { private readonly rootDir: string | undefined, private readonly guard: VersionGuard, private readonly registry: CoeditingRegistry, + private readonly editFlow: EditFlow, + private readonly liveProgressUi: LiveProgressUi, ) { this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); this.controller.commentingRangeProvider = this.rangeProvider; @@ -71,6 +89,14 @@ export class ThreadController implements vscode.Disposable { vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) => this.setThreadStatus(t, "open"), ), + // D19 (spec §6.4 v0.2.1): the comments-first "Ask Claude" gesture — a + // focused comment box, not a webview. + vscode.commands.registerCommand("cowriting.askClaude", () => this.askClaude()), + // D8: turn a machine "offer" thread into pending F4 proposal(s) (INV-5). + vscode.commands.registerCommand( + "cowriting.makeThreadEdit", + (arg: { thread: vscode.CommentThread } | vscode.CommentThread) => this.makeEditFromThreadArg(arg), + ), vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), // INV-10 (PUC-7): force VS Code to re-query commenting ranges on every gate @@ -127,6 +153,31 @@ export class ThreadController implements vscode.Disposable { return state; } + // ---- D19: comments-first ask ----------------------------------------------------- + + /** + * D19 (spec §6.4 v0.2.1): "Ask Claude" opens a focused comment box on the + * active editor's selection, or, when there is no selection, a top-anchored + * whole-document thread. The ask itself IS a comment — respondInThread (D10) + * takes it from there once the human submits it via cowriting.reply. + */ + async askClaude(): Promise { + 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"); + } + // ---- PUC-1: create on selection ------------------------------------------------- async createThreadOnSelection(firstBody = "New thread"): Promise { @@ -143,20 +194,140 @@ export class ThreadController implements vscode.Disposable { const fp = buildFingerprint(document.getText(), offsets); const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody }); this.persist(state); - this.renderThread(document, state, threadId, offsets, false); + const vsThread = this.renderThread(document, state, threadId, offsets, false); + // D10: the first message of a new thread is also "a comment on a coedited + // doc" — run the same respond loop as a reply. + void this.respondInThread(vsThread, state, threadId, firstBody); return threadId; } // ---- PUC-2: reply / resolve ----------------------------------------------------- reply(r: vscode.CommentReply): void { - const threadId = this.threadIdOf(r.thread); - const state = this.stateOfThread(r.thread); - if (!threadId || !state) return; + const vsThread = r.thread; + if (!isAuthorable(vsThread.uri.scheme) || !this.registry.isCoediting(vsThread.uri)) return; + const document = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString()); + if (!document) return; + const state = this.ensureState(document); if (this.guard.isReadOnly(state.docPath)) return; - appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text }); + let threadId = this.threadIdOf(vsThread); + if (threadId) { + appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text }); + } else { + // D10/D19: a brand-new thread created via the native "+"/comment-widget + // gesture (not via createThreadOnSelection) — VS Code hands us a real, + // still-unregistered CommentThread; register it now. + const range = vsThread.range ?? new vscode.Range(0, 0, 0, 0); + const offsets: OffsetRange = { + start: document.offsetAt(range.start), + end: document.offsetAt(range.end), + }; + const fp = buildFingerprint(document.getText(), offsets); + const created = addThread(state.artifact, fp, { author: this.currentAuthor(), body: r.text }); + threadId = created.threadId; + state.vsThreads.set(threadId, vsThread); + state.live.set(threadId, offsets); + state.orphaned.set(threadId, false); + } this.persist(state); - this.refreshComments(r.thread, state, threadId); + this.refreshComments(vsThread, state, threadId); + // D10: every human comment on a coedited doc runs a turn. + void this.respondInThread(vsThread, state, threadId, r.text); + } + + /** + * D10/PUC-8: run one reply turn for `ask`, then append the machine's reply + * (INV-8 onBehalfOf provenance) and flip the thread into an "offer" — ready + * for makeThreadEdit to cut it into pending proposal(s) (D8, INV-5). + */ + private async respondInThread( + vsThread: vscode.CommentThread, + state: DocState, + threadId: string, + ask: string, + ): Promise { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString()); + if (!doc) return; + const contextText = this.threadContextText(doc, vsThread); + 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)}`); + } + } + + /** The passage a ranged thread discusses, or the whole document for a top-anchored (D19) thread. */ + private threadContextText(doc: vscode.TextDocument, vsThread: vscode.CommentThread): string { + const range = vsThread.range; + if (range && !range.isEmpty) return doc.getText(range); + return doc.getText(); + } + + /** D19: a ranged thread targets its range; a top-anchored (whole-doc-ask) thread targets the document. */ + private targetOf(doc: vscode.TextDocument, vsThread: vscode.CommentThread): EditTarget { + const range = vsThread.range; + if (!range || range.isEmpty) return { kind: "document" }; + return { kind: "range", start: doc.offsetAt(range.start), end: doc.offsetAt(range.end) }; + } + + // ---- D8: offer -> pending proposal(s) (INV-5) ------------------------------------- + + private async makeEditFromThreadArg(arg: { thread: vscode.CommentThread } | vscode.CommentThread): Promise { + const vsThread = "thread" in arg ? arg.thread : arg; + await this.runMakeEdit(vsThread); + } + + private async runMakeEdit(vsThread: vscode.CommentThread): Promise { + 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); + 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.`, + ); + } + return ids; + } + + /** Test seam (Task 6): run the offer for a thread without the UI button. */ + async makeThreadEdit(threadId: string, docPath: string): Promise { + const vsThread = this.docs.get(docPath)?.vsThreads.get(threadId); + if (!vsThread) return []; + return this.runMakeEdit(vsThread); + } + + /** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */ + setTurnRunnerForTest(fn: TurnRunner): void { + this.turnRunner = fn; } private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void { @@ -248,7 +419,7 @@ export class ThreadController implements vscode.Disposable { threadId: string, offsets: OffsetRange, orphaned: boolean, - ): void { + ): vscode.CommentThread { const thread = state.artifact.threads.find((t) => t.id === threadId)!; const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end)); const vsThread = this.controller.createCommentThread( @@ -263,6 +434,7 @@ export class ThreadController implements vscode.Disposable { state.vsThreads.set(threadId, vsThread); state.live.set(threadId, offsets); state.orphaned.set(threadId, orphaned); + return vsThread; } private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void { diff --git a/test/e2e/suite/coediting.test.ts b/test/e2e/suite/coediting.test.ts index a9b885a..3047d2b 100644 --- a/test/e2e/suite/coediting.test.ts +++ b/test/e2e/suite/coediting.test.ts @@ -42,6 +42,12 @@ suite("coediting registry (PUC-7)", () => { // rendered thread (not the sidecar-backed data); re-entering restores it. test("non-entered doc gets no surfaces; exit detaches; re-enter restores threads", async () => { const api = await activateApi(); + // Task 6 (D10): createThreadOnSelection now also fires the respond-in- + // thread turn once the doc is coediting — stub it off (not under test + // here; this suite asserts render/hide/restore, not the reply loop). + api.threadController.setTurnRunnerForTest(async () => { + throw new Error("coediting suite: reply-loop turn stubbed off"); + }); 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 diff --git a/test/e2e/suite/commentLoop.test.ts b/test/e2e/suite/commentLoop.test.ts new file mode 100644 index 0000000..4136ace --- /dev/null +++ b/test/e2e/suite/commentLoop.test.ts @@ -0,0 +1,47 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import { activateApi, settleUntil } from "./helpers"; + +// Task 6 (D19/D10/D8, PUC-8): the comments-first ask + comment→reply→offer→ +// proposal loop, with a stubbed reply turn (no real @cline/sdk call). Runs on +// an untitled buffer (F8 routes it to the global sidecar) so this suite has no +// workspace-folder dependency. +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 + }); +}); diff --git a/test/e2e/suite/crossrung.test.ts b/test/e2e/suite/crossrung.test.ts index 9ac7f1c..ab2bbba 100644 --- a/test/e2e/suite/crossrung.test.ts +++ b/test/e2e/suite/crossrung.test.ts @@ -57,6 +57,13 @@ suite("F5 cross-rung round-trip (host E2E)", () => { const DOC = "docs/crossrung.md"; const doc = await open(DOC); const api = await getApi(); + // Task 6 (D10): createThreadOnSelection also fires the respond-in-thread + // turn now — stub it off so it can't race the forge stand-in's reply + // below (this test asserts an EXACT 2-message sequence: editor note + + // forge reply). + api.threadController.setTurnRunnerForTest(async () => { + throw new Error("crossrung suite: reply-loop turn stubbed off"); + }); const target = "portable record"; const start = doc.getText().indexOf(target); const editor = vscode.window.activeTextEditor!; diff --git a/test/e2e/suite/outOfWorkspace.test.ts b/test/e2e/suite/outOfWorkspace.test.ts index adabf77..89607aa 100644 --- a/test/e2e/suite/outOfWorkspace.test.ts +++ b/test/e2e/suite/outOfWorkspace.test.ts @@ -41,6 +41,12 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)", await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); const api = await getApi(); + // Task 6 (D10): createThreadOnSelection (used later in this test) also + // fires the respond-in-thread turn now — stub it off (this "no LLM" suite + // asserts the programmatic propose/accept seam, not the reply loop). + api.threadController.setTurnRunnerForTest(async () => { + throw new Error("out-of-workspace suite: reply-loop turn stubbed off"); + }); const key = uri.toString(); const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1"); diff --git a/test/e2e/suite/threads.test.ts b/test/e2e/suite/threads.test.ts index 347c1f8..05f3684 100644 --- a/test/e2e/suite/threads.test.ts +++ b/test/e2e/suite/threads.test.ts @@ -41,6 +41,17 @@ async function getApi(): Promise { return api; } +// Task 6 (D10): every human comment on a coedited doc now also fires the +// respond-in-thread turn (createThreadOnSelection/reply). This suite predates +// that loop and asserts exact message sequences unrelated to it — stub the +// turn to reject so the loop runs harmlessly (no real @cline/sdk call, and no +// extra machine message lands in the sidecar this suite's assertions read). +function stubReplyLoop(api: CowritingApi): void { + api.threadController.setTurnRunnerForTest(async () => { + throw new Error("F2 suite: reply-loop turn stubbed off (not under test here)"); + }); +} + // Reaches the controller's internal docs map for reply/resolve handlers (which // expect a live vscode.CommentThread). Spec §6.8 "drive the ThreadController and // assert Comments-controller state" fallback. @@ -67,6 +78,7 @@ suite("F2 region-anchored threads (host E2E)", () => { test("create on selection persists a thread sidecar", async () => { const doc = await openSample(); const api = await getApi(); + stubReplyLoop(api); const start = doc.getText().indexOf("quick brown fox"); const editor = vscode.window.activeTextEditor!; editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length)); @@ -89,6 +101,7 @@ suite("F2 region-anchored threads (host E2E)", () => { test("reply appends and resolve flips status, both persisted", async () => { const api = await getApi(); + stubReplyLoop(api); const art0 = readSidecar(); const threadId = art0.threads[0].id; -- 2.39.5 From c9975ba9e67c01c8190c2ed38f8023304fb294e8 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 09:04:32 -0700 Subject: [PATCH 11/18] fix: route document asks to comment-first askClaude (plan T6 Step 4); machine-author guard + offer cleanup Co-Authored-By: Claude Fable 5 --- src/editFlow.ts | 57 ++++++++++----------- src/extension.ts | 36 +++++++++++++- src/threadController.ts | 32 +++++++++--- test/e2e/suite/commentLoop.test.ts | 43 ++++++++++++++++ test/e2e/suite/f12Reach.test.ts | 79 +++++++++++++++++++----------- 5 files changed, 177 insertions(+), 70 deletions(-) diff --git a/src/editFlow.ts b/src/editFlow.ts index 1cf440d..9c18d29 100644 --- a/src/editFlow.ts +++ b/src/editFlow.ts @@ -1,13 +1,15 @@ /** * EditFlow — the host edit-turn flow (F11/F12), extracted from the review * webview controller ahead of its sunset (native-surfaces migration, spec - * §6.10). Owns the `cowriting.editDocument` command, the instruction prompt + - * progress-wrapped "ask Claude" gesture (INV-10 gate, INV-8 host-only LLM - * surface), and the low-level turn→proposal(s) cut (`runEditAndPropose`, - * INV-39/40). Never mutates the document directly — every turn lands as one or - * more F4 proposals via `ProposalController.propose` (INV-10). Reachable from - * both the review webview (delegates here) and, from Task 6 on, the thread - * controller — vscode-API-only, no webview state. + * §6.10). Owns the instruction prompt + progress-wrapped "ask Claude" gesture + * (INV-10 gate, INV-8 host-only LLM surface), and the low-level turn→proposal(s) + * cut (`runEditAndPropose`, INV-39/40). Never mutates the document directly — + * every turn lands as one or more F4 proposals via `ProposalController.propose` + * (INV-10). `runEditAndPropose` is reachable from the review webview (via this + * class's `askClaude`) and, from Task 6 on, the thread controller + * (`ThreadController.runMakeEdit`) — vscode-API-only, no webview state. The + * `cowriting.editDocument` command lives in extension.ts (Finding-1 fix) so it + * can hand off to `ThreadController.askClaude()`. */ import * as vscode from "vscode"; import type { ProposalController } from "./proposalController"; @@ -67,38 +69,29 @@ export class EditFlow implements vscode.Disposable { private readonly liveProgressUi: LiveProgressUi, private readonly registry: CoeditingRegistry, ) { - this.disposables.push( - // F11: document-scoped Ask-Claude (also reused by #42's reach gateways). - // Edits a markdown doc; the rewrite is diffed into F4 proposals. - // #42 (INV-38): the editor/title/context (tab) entry passes the clicked - // tab's resource Uri — target THAT document, opening it if it isn't already - // an open buffer (mirrors showTrackChangesPreview's #41 resolution); the - // palette / keybinding / editor/context pass nothing → the active editor. - vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => { - const doc = uri - ? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ?? - (await vscode.workspace.openTextDocument(uri)) - : vscode.window.activeTextEditor?.document; - if (!doc || doc.languageId !== "markdown") { - void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it."); - return; - } - void this.askClaude(doc, { kind: "document" }); - }), - ); + // Code-review fix (Finding 1, session native-surfaces-exec): the + // `cowriting.editDocument` command used to be registered here, calling + // `this.askClaude(doc, {kind:"document"})` — which prompts via the (now + // rejecting-stub) `askEditInstruction`, a silent dead end. That command is now + // registered in extension.ts, AFTER ThreadController exists, and hands off to + // `ThreadController.askClaude()` (the comments-first ask, D19) instead. This + // class's `askClaude`/`askEditInstruction` remain: the review webview's + // toolbar ask (`trackChangesPreview.ts`) still delegates to them, slated for + // deletion alongside that webview in Task 8. } /** * F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret * surface out of the sealed webview, INV-8/35), run the edit turn, and surface - * the result as F4 proposal(s). UI wrapper around `runEditAndPropose`. Called - * both by the `cowriting.editDocument` command above and (via delegation) by - * the review webview's `askClaude` toolbar intent (either scope). + * the result as F4 proposal(s). UI wrapper around `runEditAndPropose`. As of the + * Finding-1 fix, its only production caller is the review webview's `askClaude` + * toolbar intent (either scope) — `cowriting.editDocument`/`cowriting.editSelection` + * now delegate to `ThreadController.askClaude()` instead (D19). Both die together + * in Task 8 when the webview is sunset. */ async askClaude(document: vscode.TextDocument, target: EditTarget): Promise { - // INV-10: the choke point for BOTH the editDocument command and the webview's - // askClaude toolbar intent (either scope) — a non-entered doc gets the warning, - // not a turn. + // INV-10: the choke point for the webview's askClaude toolbar intent (either + // scope) — a non-entered doc gets the warning, not a turn. if (!this.registry.isCoediting(document.uri)) { void vscode.window.showWarningMessage(NOT_COEDITING_WARNING); return; diff --git a/src/extension.ts b/src/extension.ts index d39c3a7..18883bf 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -323,11 +323,43 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef }), ); + // Code-review fix (Finding 1, session native-surfaces-exec): `cowriting.editDocument` + // used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompts via + // `askEditInstruction` — a rejecting stub since Task 6 sunset the input webview + // (spec §6.10). Reached with no selection (the common case for `cowriting.edit`), + // that was a SILENT dead end: the reject fired before EditFlow.askClaude's own try/ + // catch and was `void`'d, so no toast, no thread, nothing. This command now resolves + // its target document exactly as before (a tab's clicked URI, falling back to the + // active editor), focuses it, and hands off to the SAME comments-first ask as + // `cowriting.editSelection` — `ThreadController.askClaude()` top-anchors a + // whole-document ask when the (now-focused) editor's selection is empty (D19). Kept + // registered + hidden from the palette for the host E2E harness and #42's + // tab-targeting behavior; `EditFlow.runEditAndPropose` (untouched) stays the E2E seam + // for driving a turn without the native comment UI — see f12Reach.test.ts / + // f11Toolbar.test.ts / f12Accept.test.ts / f12Review.test.ts. + context.subscriptions.push( + vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => { + const doc = uri + ? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ?? + (await vscode.workspace.openTextDocument(uri)) + : vscode.window.activeTextEditor?.document; + if (!doc || doc.languageId !== "markdown") { + void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it."); + return; + } + if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) { + await vscode.window.showTextDocument(doc); + } + await threadController.askClaude(); + }), + ); + // The single user-facing "Ask Claude to Edit" gesture (one command, one // keybinding, one menu entry). It routes to the selection flow (editSelection) // or the whole-document flow (editDocument) by selection/context — see - // routeEdit. The two underlying commands stay registered for the host E2E - // harness and the internal seams, but are hidden from the palette. + // routeEdit. Both underlying commands now end at ThreadController.askClaude() + // (comments-first ask, D19); they stay registered for the host E2E harness and + // the internal seams, but are hidden from the palette. context.subscriptions.push( vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => { const editor = vscode.window.activeTextEditor; diff --git a/src/threadController.ts b/src/threadController.ts index a329bbc..8e311e4 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -192,12 +192,17 @@ export class ThreadController implements vscode.Disposable { end: document.offsetAt(editor.selection.end), }; const fp = buildFingerprint(document.getText(), offsets); - const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody }); + const author = this.currentAuthor(); + const { threadId } = addThread(state.artifact, fp, { author, body: firstBody }); this.persist(state); const vsThread = this.renderThread(document, state, threadId, offsets, false); // D10: the first message of a new thread is also "a comment on a coedited - // doc" — run the same respond loop as a reply. - void this.respondInThread(vsThread, state, threadId, firstBody); + // doc" — run the same respond loop as a reply. Finding 2 guard: only for a + // human-authored message (never re-trigger the loop off the machine's own + // words — see reply() below for the matching guard). + if (author.kind !== "agent") { + void this.respondInThread(vsThread, state, threadId, firstBody); + } return threadId; } @@ -210,9 +215,10 @@ export class ThreadController implements vscode.Disposable { if (!document) return; const state = this.ensureState(document); if (this.guard.isReadOnly(state.docPath)) return; + const author = this.currentAuthor(); let threadId = this.threadIdOf(vsThread); if (threadId) { - appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text }); + appendMessage(state.artifact, threadId, { author, body: r.text }); } else { // D10/D19: a brand-new thread created via the native "+"/comment-widget // gesture (not via createThreadOnSelection) — VS Code hands us a real, @@ -223,7 +229,7 @@ export class ThreadController implements vscode.Disposable { end: document.offsetAt(range.end), }; const fp = buildFingerprint(document.getText(), offsets); - const created = addThread(state.artifact, fp, { author: this.currentAuthor(), body: r.text }); + const created = addThread(state.artifact, fp, { author, body: r.text }); threadId = created.threadId; state.vsThreads.set(threadId, vsThread); state.live.set(threadId, offsets); @@ -231,8 +237,14 @@ export class ThreadController implements vscode.Disposable { } this.persist(state); this.refreshComments(vsThread, state, threadId); - // D10: every human comment on a coedited doc runs a turn. - void this.respondInThread(vsThread, state, threadId, r.text); + // D10: every HUMAN comment on a coedited doc runs a turn. Finding 2 guard: + // `cowriting.reply` is the ingress for both a genuine human reply and (in + // principle) a re-dispatched machine message — never loop the machine's own + // reply back through respondInThread (it would double-turn on Claude's words + // and re-flip an already-"offer" thread). + if (author.kind !== "agent") { + void this.respondInThread(vsThread, state, threadId, r.text); + } } /** @@ -310,6 +322,12 @@ export class ThreadController implements vscode.Disposable { const target = this.targetOf(doc, vsThread); const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask); vsThread.contextValue = "offer-done"; + // Finding 3 (seam hygiene): drop the offer once it's spent — otherwise the + // `makeThreadEdit(threadId, docPath)` test seam (or a stray second UI click, + // now dead per contextValue but still a valid direct call) could re-run the + // same edit a second time. A future offer on this thread is a fresh + // respondInThread() call, which re-populates the WeakMap entry. + this.offers.delete(vsThread); if (ids.length) { void vscode.window.showInformationMessage( `Cowriting: ${ids.length} pending change${ids.length === 1 ? "" : "s"} landed in the buffer — ✓ Keep / ✗ Reject there.`, diff --git a/test/e2e/suite/commentLoop.test.ts b/test/e2e/suite/commentLoop.test.ts index 4136ace..a3ab961 100644 --- a/test/e2e/suite/commentLoop.test.ts +++ b/test/e2e/suite/commentLoop.test.ts @@ -44,4 +44,47 @@ suite("comment → reply → offer → proposal (PUC-8, D10/D19)", () => { const id = await api.threadController.createThreadOnSelection("hello"); assert.strictEqual(id, undefined); // gate refuses thread creation entirely }); + + // Finding 1 (code review, session native-surfaces-exec): `cowriting.edit` with NO + // selection used to route through `cowriting.editDocument` → the removed + // `askEditInstruction` webview prompt — a rejecting stub `void`'d before any + // try/catch, i.e. a completely silent dead end (no toast, no thread). It must now + // reach ThreadController.askClaude()'s top-anchored whole-document path (D19) — + // the same comments-first ask the selection route already used. There is no API to + // drive VS Code's native comment-widget submission from a host E2E test (the actual + // comment→reply→offer→proposal round trip is exercised above via the + // createThreadOnSelection seam), so this asserts the routing itself: the gesture + // reaches ThreadController.askClaude (never the old broken stub) and does not throw. + test("cowriting.edit with no selection on a coedited doc reaches the top-anchored ask path, not a silent dead end", async () => { + const api = await activateApi(); + const doc = await vscode.workspace.openTextDocument({ + language: "markdown", + content: "# T\n\nA document-level ask with no selection.\n", + }); + const ed = await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + // Defensive stubs, mirroring the suite's other test — no real turn should fire + // from this gesture alone (no comment text is ever submitted), but guard against + // a real @cline/sdk call if the routing regresses. + api.threadController.setTurnRunnerForTest(async () => ({ replacement: "stub", model: "stub", sessionId: "s1" })); + api.editFlow.setEditTurnForTest(async () => ({ replacement: "stub", model: "stub", sessionId: "s1" })); + ed.selection = new vscode.Selection(0, 0, 0, 0); // no selection + + let askClaudeCalls = 0; + const origAskClaude = api.threadController.askClaude.bind(api.threadController); + api.threadController.askClaude = async () => { + askClaudeCalls++; + return origAskClaude(); + }; + try { + await vscode.commands.executeCommand("cowriting.edit"); + } finally { + api.threadController.askClaude = origAskClaude; + } + assert.strictEqual( + askClaudeCalls, + 1, + "cowriting.edit (no selection) reached ThreadController.askClaude — not the removed editDocument prompt stub", + ); + }); }); diff --git a/test/e2e/suite/f12Reach.test.ts b/test/e2e/suite/f12Reach.test.ts index 4aa4468..7b3aff4 100644 --- a/test/e2e/suite/f12Reach.test.ts +++ b/test/e2e/suite/f12Reach.test.ts @@ -72,9 +72,17 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { // PUC-3 behavior: editDocument invoked with a tab URI targets THAT document, // not whatever editor happens to be active (mirrors #41's clicked-doc resolution). + // Finding 1 fix (native-surfaces code review): editDocument no longer prompts via + // the removed askEditInstruction webview stub (that path threw a silent, `void`'d, + // unhandled rejection — no toast, no thread, no proposal). It now resolves/focuses + // its target document and hands off to ThreadController.askClaude() (D19), same as + // editSelection. The turn→proposal cut itself (EditFlow.runEditAndPropose against a + // {kind:"document"} target) is exercised directly, without the command, in + // f11Toolbar.test.ts / f12Accept.test.ts / f12Review.test.ts — this test's job is + // the URI-targeting/focus behavior, plus confirming the routing actually reaches + // askClaude (proving Finding 1's dead end is gone). test("editDocument(uri) targets the clicked tab's document, not the active editor", async () => { const api = await getApi(); - const editFlow = api.editFlow; // Doc A is the active editor; Doc B is the "clicked tab" we pass by URI. const a = await freshDoc("docs/f12-active.md", "# Active\n\nThe active editor paragraph.\n"); @@ -82,55 +90,68 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { await vscode.window.showTextDocument(a.doc); await settle(); - // Stub the document instruction prompt (the webview can't run in CI) + the LLM turn. - const origPrompt = editFlow.askEditInstruction; - editFlow.askEditInstruction = async () => "rewrite it"; - editFlow.setEditTurnForTest(async () => ({ - replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n", - model: "sonnet", - sessionId: "e2e-f12-tab", - })); + let askClaudeCalls = 0; + // Capture the active-editor doc INSIDE the spy, before delegating to the real + // askClaude — askClaude's own `workbench.action.addComment` side effect moves + // focus to the ephemeral comment-input widget (a `comment://` URI), which would + // make a post-hoc `activeTextEditor` check meaningless. What we're proving here + // is editDocument's OWN resolve-and-focus step ran against the right document + // before handing off. + let focusedDocAtHandoff: string | undefined; + const origAskClaude = api.threadController.askClaude.bind(api.threadController); + api.threadController.askClaude = async () => { + askClaudeCalls++; + focusedDocAtHandoff = vscode.window.activeTextEditor?.document.uri.toString(); + return origAskClaude(); + }; try { await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri); await settle(); } finally { - editFlow.askEditInstruction = origPrompt; + api.threadController.askClaude = origAskClaude; } - // The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none. - assert.ok(api.proposalController.listProposals(b.doc).length >= 1, "tab doc B received the document-edit proposal(s)"); assert.strictEqual( - api.proposalController.listProposals(a.doc).length, - 0, - "active doc A was NOT edited — editDocument honored the tab URI", + askClaudeCalls, + 1, + "editDocument(uri) reached ThreadController.askClaude — not the removed prompt stub", + ); + assert.strictEqual( + focusedDocAtHandoff, + b.doc.uri.toString(), + "editDocument(uri) focused the TAB doc B, not the previously-active doc A, before handing off to askClaude", ); - // F12 (INV-48): EditorProposalController optimistically applies proposals into the - // buffer, so the proposed text is now in the tab doc B. Confirm one of the proposal - // texts is present (the tab doc was edited, not A). - assert.ok(b.doc.getText().includes("REWRITTEN"), "F12 optimistic-apply: proposed text in tab doc B"); }); // No URI arg (palette / keybinding) → fall back to the active editor. test("editDocument() with no arg targets the active editor", async () => { const api = await getApi(); - const editFlow = api.editFlow; const a = await freshDoc("docs/f12-noarg.md", "# No arg\n\nThe active doc paragraph here.\n"); await vscode.window.showTextDocument(a.doc); await settle(); - const origPrompt = editFlow.askEditInstruction; - editFlow.askEditInstruction = async () => "rewrite it"; - editFlow.setEditTurnForTest(async () => ({ - replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n", - model: "sonnet", - sessionId: "e2e-f12-noarg", - })); + let askClaudeCalls = 0; + // See the sibling test above for why this is captured inside the spy rather + // than after askClaude() returns. + let focusedDocAtHandoff: string | undefined; + const origAskClaude = api.threadController.askClaude.bind(api.threadController); + api.threadController.askClaude = async () => { + askClaudeCalls++; + focusedDocAtHandoff = vscode.window.activeTextEditor?.document.uri.toString(); + return origAskClaude(); + }; try { await vscode.commands.executeCommand("cowriting.editDocument"); await settle(); } finally { - editFlow.askEditInstruction = origPrompt; + api.threadController.askClaude = origAskClaude; } - assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg"); + + assert.strictEqual(askClaudeCalls, 1, "editDocument() with no arg reached ThreadController.askClaude"); + assert.strictEqual( + focusedDocAtHandoff, + a.doc.uri.toString(), + "editDocument() with no arg kept the active editor's doc active before handing off to askClaude", + ); }); }); -- 2.39.5 From 17fc01e8d8eb843dc538c1148818112d354cbd85 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 14:23:11 -0700 Subject: [PATCH 12/18] feat: authorship + change annotations in the built-in Markdown preview (D3/D21, PUC-3); cowriting.annotations toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 7 of the native-surfaces migration plan. Pure previewAnnotations.ts (annotateSource + cowritingMarkdownItPlugin) reuses trackChangesModel.ts's sentinel discipline (#33/#47), generalized to a 3-way ins-claude/ins-human/del tag (exported injectSentinels/sentinelsToSpans, colorByAuthor unaffected, 84/84 existing tests pass unmodified). Host hook in extension.ts wires registry/diffView/attribution/proposals/config into the plugin, exposed via CowritingApi.extendMarkdownIt + a previewAnnotationHost test seam. cowriting.annotations setting + toggleAnnotations command/menu. Q4 mermaid (previewScripts + options.highlight fence override) implemented per the proven bierner.markdown-mermaid pattern; intra-diagram diff augmentation scoped out (unverifiable via the structural E2E harness) — see task-7-report.md. Co-Authored-By: Claude Fable 5 --- esbuild.mjs | 23 ++- media/preview-annotations.css | 43 +++++ media/preview-mermaid.ts | 53 ++++++ package.json | 27 +++ src/extension.ts | 61 +++++++ src/previewAnnotations.ts | 197 ++++++++++++++++++++++ src/trackChangesModel.ts | 76 ++++++--- test/e2e/suite/previewAnnotations.test.ts | 79 +++++++++ test/previewAnnotations.test.ts | 46 +++++ 9 files changed, 576 insertions(+), 29 deletions(-) create mode 100644 media/preview-annotations.css create mode 100644 media/preview-mermaid.ts create mode 100644 src/previewAnnotations.ts create mode 100644 test/e2e/suite/previewAnnotations.test.ts create mode 100644 test/previewAnnotations.test.ts diff --git a/esbuild.mjs b/esbuild.mjs index 916f33a..762ec83 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -45,15 +45,34 @@ const previewOptions = { logLevel: "info", }; +/** @type {import('esbuild').BuildOptions} */ +const previewMermaidOptions = { + entryPoints: ["media/preview-mermaid.ts"], + outfile: "out/media/preview-mermaid.js", + bundle: true, + // Task 7 (Q4): contributed via markdown.previewScripts into the BUILT-IN + // preview's webview — same browser/IIFE shape as previewOptions above, and + // the same reason mermaid is bundled here (never in the host bundle). + platform: "browser", + format: "iife", + target: "es2020", + sourcemap: true, + logLevel: "info", +}; + if (watch) { const ctx = await context(options); const ctxLive = await context(liveTurnOptions); const ctxPreview = await context(previewOptions); - await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch()]); + const ctxPreviewMermaid = await context(previewMermaidOptions); + await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch(), ctxPreviewMermaid.watch()]); console.log("esbuild: watching…"); } else { await build(options); await build(liveTurnOptions); await build(previewOptions); - console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js"); + await build(previewMermaidOptions); + console.log( + "esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js + out/media/preview-mermaid.js", + ); } diff --git a/media/preview-annotations.css b/media/preview-annotations.css new file mode 100644 index 0000000..9123dc5 --- /dev/null +++ b/media/preview-annotations.css @@ -0,0 +1,43 @@ +/* + * Task 7 (D3/D21, PUC-3) — authorship + change annotations for the BUILT-IN VS + * Code Markdown preview (`markdown.previewStyles`). Same F10 vocabulary as the + * (sunsetting) custom webview's `media/preview.css`: style = operation + * (underline = inserted, strikethrough = removed), color = author (human + * green, Claude blue) — `cw-del` has no author variant (a single fixed + * struck-red mark; see `previewAnnotations.ts`/`trackChangesModel.ts`'s "del" + * sentinel tag). Light-theme defaults below; `body.vscode-dark` (also applied + * on high-contrast dark) overrides with the same palette the webview preview + * already ships, for continuity across both review surfaces. + */ +.cw-ins-claude { + background: rgba(9, 105, 218, 0.12); + border-bottom: 2px solid #0969da; + text-decoration: none; +} +.cw-ins-human { + background: rgba(26, 127, 55, 0.12); + border-bottom: 2px solid #1a7f37; + text-decoration: none; +} +.cw-del { + background: rgba(207, 34, 46, 0.1); + text-decoration: line-through; + text-decoration-color: #cf222e; + opacity: 0.75; +} + +body.vscode-dark .cw-ins-claude, +body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-ins-claude { + background: rgba(88, 166, 255, 0.15); + border-bottom-color: #58a6ff; +} +body.vscode-dark .cw-ins-human, +body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-ins-human { + background: rgba(63, 185, 80, 0.14); + border-bottom-color: #3fb950; +} +body.vscode-dark .cw-del, +body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-del { + background: rgba(248, 81, 73, 0.11); + text-decoration-color: #f85149; +} diff --git a/media/preview-mermaid.ts b/media/preview-mermaid.ts new file mode 100644 index 0000000..221b176 --- /dev/null +++ b/media/preview-mermaid.ts @@ -0,0 +1,53 @@ +/** + * Task 7 (Q4 mermaid check, D3/D21): the built-in VS Code Markdown preview does + * not render mermaid fences on its own — `previewAnnotations.ts`'s + * `cowritingMarkdownItPlugin` teaches ```mermaid fences to render as + * `
SRC
` (via `options.highlight`, the same + * extension point the built-in preview's own fence rule already calls); this + * script (contributed via `markdown.previewScripts`) is what actually turns + * those into diagrams — mermaid needs a DOM, so it runs here, in the preview's + * webview, never in the extension host. Bundled by esbuild as a standalone + * IIFE → out/media/preview-mermaid.js, so mermaid never enters the host + * bundle (matching the sealed webview's `media/preview.ts` precedent). + * + * Contributed preview scripts are reloaded on every content change (VS Code + * docs), so running at module top-level is sufficient; the + * `vscode.markdown.updateContent` listener is added defensively to also cover + * in-place content updates that don't reload the script (mirrors the proven + * `bierner.markdown-mermaid` extension's own approach). + * + * SCOPE NOTE (Q4 finding, recorded per the migration plan's Task 7 Step 2.6): + * this renders the CURRENT diagram only — it does NOT re-emit a changed + * diagram through `mermaidDiff.ts`'s intra-diagram diff/legend augmentation + * (the F7.1 feature the sealed webview preview has). That augmentation needs a + * block-level (not word-hunk-level) diff pass wired into `annotateSource`, + * which was judged out of scope for this increment — it cannot be verified by + * the automated E2E harness (the built-in preview's DOM/CSP sandbox isn't + * queryable from a host test) and shipping it unverified risked a silent + * regression. Basic mermaid rendering (this file) IS wired and is exercised by + * the same proven pattern as the real `bierner.markdown-mermaid` extension. + */ +import mermaid from "mermaid"; + +function theme(): "dark" | "default" { + return document.body.classList.contains("vscode-dark") || document.body.classList.contains("vscode-high-contrast") + ? "dark" + : "default"; +} + +async function run(): Promise { + const nodes = Array.from(document.querySelectorAll("pre.mermaid")); + if (nodes.length === 0) return; + mermaid.initialize({ startOnLoad: false, theme: theme(), securityLevel: "strict" }); + try { + await mermaid.run({ nodes }); + } catch { + // mermaid.run already marks failed nodes; ensure a visible chip per failure. + for (const n of nodes) { + if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true"); + } + } +} + +window.addEventListener("vscode.markdown.updateContent", () => void run()); +void run(); diff --git a/package.json b/package.json index 1b1bbd2..5f927ce 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,13 @@ "onStartupFinished" ], "contributes": { + "markdown.markdownItPlugins": true, + "markdown.previewStyles": [ + "./media/preview-annotations.css" + ], + "markdown.previewScripts": [ + "./out/media/preview-mermaid.js" + ], "configuration": { "title": "Cowriting", "properties": { @@ -25,6 +32,11 @@ "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." + }, + "cowriting.annotations": { + "type": "boolean", + "default": true, + "description": "Show authorship + change annotations in the Markdown preview for coedited documents." } } }, @@ -147,6 +159,12 @@ "title": "✦ Make this edit", "category": "Cowriting", "icon": "$(sparkle)" + }, + { + "command": "cowriting.toggleAnnotations", + "title": "Toggle Annotations", + "category": "Cowriting", + "icon": "$(eye)" } ], "menus": { @@ -187,6 +205,10 @@ "command": "cowriting.askClaude", "when": "editorLangId == markdown && cowriting.isCoediting" }, + { + "command": "cowriting.toggleAnnotations", + "when": "editorLangId == markdown && cowriting.isCoediting" + }, { "command": "cowriting.makeThreadEdit", "when": "false" @@ -219,6 +241,11 @@ "when": "resourceLangId == markdown && cowriting.isCoediting", "group": "navigation@2" }, + { + "command": "cowriting.toggleAnnotations", + "when": "resourceLangId == markdown && cowriting.isCoediting", + "group": "navigation@3" + }, { "command": "cowriting.markReviewed", "when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot", diff --git a/src/extension.ts b/src/extension.ts index 18883bf..c9877d9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,6 +18,8 @@ import { EditorProposalController } from "./editorProposalController"; import { isAuthorable, routeEdit } from "./workspacePath"; import { CoeditingRegistry } from "./coeditingRegistry"; import { ScmSurfaceController } from "./scmSurface"; +import type MarkdownIt from "markdown-it"; +import { cowritingMarkdownItPlugin, type AnnotationInputs } from "./previewAnnotations"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; @@ -34,6 +36,13 @@ export interface CowritingApi { editorProposalController: EditorProposalController; coeditingRegistry: CoeditingRegistry; scmSurfaceController: ScmSurfaceController; + /** Task 7 test seam: the REAL production `inputsFor`, so E2E exercises the + * actual registry/diffView/attribution/proposals/config wiring (not a + * hand-rolled reimplementation of it). */ + previewAnnotationHost: { inputsFor(env: unknown): AnnotationInputs | undefined }; + /** Task 7 (D3/D21): the markdown-it plugin factory VS Code's built-in preview + * loads via `markdown.markdownItPlugins` (package.json). */ + extendMarkdownIt: (md: unknown) => unknown; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { @@ -391,6 +400,56 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef vscode.workspace.textDocuments.forEach(renderIfOpen); context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen)); + // --- Task 7 (D3/D21, PUC-3): annotations in the BUILT-IN Markdown preview --- + // `env.currentDocument` (VS Code's markdown-it render env) is an OBSERVED + // preview-engine behavior, not a documented contract, so `lastActiveCoedited` + // is the fallback that keeps single-doc correctness when it's absent — the + // last editor that WAS coediting when it lost focus (e.g. focus moved to the + // preview pane itself) stays the annotation target until a different + // coedited doc becomes active. + let lastCoeditedUri: string | undefined; + const trackLastCoedited = (ed: vscode.TextEditor | undefined) => { + if (ed && coeditingRegistry.isCoediting(ed.document.uri)) lastCoeditedUri = ed.document.uri.toString(); + }; + trackLastCoedited(vscode.window.activeTextEditor); + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(trackLastCoedited), + // The active editor doesn't itself change when `cowriting.coeditDocument` + // enters IT into coediting — re-check on every gate change too, so the + // very first render after entering already has an annotation target. + coeditingRegistry.onDidChange(() => trackLastCoedited(vscode.window.activeTextEditor)), + ); + + const previewAnnotationHost = { + inputsFor(env: unknown): AnnotationInputs | undefined { + const envUri = (env as { currentDocument?: { toString(): string } } | undefined)?.currentDocument?.toString(); + const key = envUri && coeditingRegistry.list().includes(envUri) ? envUri : lastCoeditedUri; + if (!key) return undefined; + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); + if (!doc) return undefined; + const baseline = diffViewController.getBaseline(key); + return { + baselineText: baseline?.text, + baselineReason: baseline?.reason, + spans: attributionController.spansFor(doc), + proposals: proposalController.listProposals(doc), + enabled: vscode.workspace.getConfiguration("cowriting").get("annotations", true), + }; + }, + }; + + // Toggle: flips `cowriting.annotations` + refreshes the built-in preview + // (VS Code re-invokes extendMarkdownIt's plugin on the next render — no + // extension-side re-render call exists beyond the standard refresh command). + context.subscriptions.push( + vscode.commands.registerCommand("cowriting.toggleAnnotations", async () => { + const config = vscode.workspace.getConfiguration("cowriting"); + const current = config.get("annotations", true); + await config.update("annotations", !current, vscode.ConfigurationTarget.Global); + await vscode.commands.executeCommand("markdown.preview.refresh"); + }), + ); + return { threadController, attributionController, @@ -404,6 +463,8 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef editorProposalController, coeditingRegistry, scmSurfaceController, + previewAnnotationHost, + extendMarkdownIt: (md: unknown) => cowritingMarkdownItPlugin(md as MarkdownIt, previewAnnotationHost), }; } diff --git a/src/previewAnnotations.ts b/src/previewAnnotations.ts new file mode 100644 index 0000000..c9c6b6b --- /dev/null +++ b/src/previewAnnotations.ts @@ -0,0 +1,197 @@ +/** + * previewAnnotations — Task 7 (D3/D21, PUC-3): a pure, vscode-free markdown-it + * plugin that annotates the BUILT-IN VS Code Markdown preview with the F10 + * authorship/change-mark vocabulary (`.cw-ins-claude` / `.cw-ins-human` / + * `.cw-del`) — the native-surfaces migration's replacement for the sunset + * webview's review body (spec §6.4/§6.10). Reuses the shipped sentinel + * discipline (`injectSentinels`/`sentinelsToSpans`, #33/#47-hardened) instead + * of inventing a new one: `annotateSource` computes PUA-sentinel-wrapped + * markdown SOURCE (never HTML) against the F6/F7 baseline + F3 spans + F4 + * pending proposals; `cowritingMarkdownItPlugin` wires that transform into the + * shared `markdown-it` instance (the SAME instance VS Code's built-in preview + * uses via `markdown.markdownItPlugins`) as (a) a core rule — swap `state.src` + * before `normalize` — and (b) a full-render wrapper that turns surviving + * sentinel pairs into ``. + * + * Implementation note (resolved drift from the migration plan's Task 7 sketch, + * which described (b) as "a text renderer rule"): `sentinelsToSpans` is a + * stateful, single-pass walk over one HTML string — installing it as a + * PER-TOKEN `renderer.rules.text` hook would reset that state at every text + * token boundary and silently break the "survives emphasis/tag boundaries" + * invariant (#33 CASE3) for any span whose sentinel-open and sentinel-close + * land in DIFFERENT text tokens (e.g. either side of a `**bold**` run — the + * exact scenario CASE3 exists to cover). Wrapping `md.renderer.render` instead + * runs the walk ONCE over the fully-assembled HTML, exactly like the F9/F10 + * `colorByAuthor` precedent (`render(injected)` — a whole-block HTML string, + * not a token fragment) — this is what actually delivers the stated invariant, + * robust to callers that invoke `md.render()` directly or `md.parse()` + + * `md.renderer.render()` separately (VS Code's own scroll-sync `data-line` + * injection uses the latter). + * + * `host.inputsFor(env)` is the ONE thin, vscode-touching seam (owned by + * `extension.ts`): `env.currentDocument` is an OBSERVED VS Code preview-engine + * behavior, not a documented contract, so the host falls back to the last + * actively-coedited document when it is absent (see extension.ts's + * `lastActiveCoedited`). + */ +import type MarkdownIt from "markdown-it"; +import { + injectSentinels, + landedTextOf, + sentinelsToSpans, + splitBlocksWithRanges, + wordEditHunks, + type AuthorSpan, + type ProposalView, + type TaggedSpan, +} from "./trackChangesModel"; + +export interface AnnotationInputs { + baselineText: string | undefined; + baselineReason: "entered" | "pinned" | "head" | undefined; + spans: AuthorSpan[]; + proposals: ProposalView[]; + enabled: boolean; +} + +/** + * Inject the sentinel vocabulary into `text` block-by-block (INV-23: code/ + * mermaid fences stay atomic — never sentinel-injected, never word-refined) so + * the delimiter-run + block-marker safety `injectSentinels` carries applies + * PER BLOCK, not just at the very top of the whole document (its clamps are + * anchored to a block's own start, matching every other caller — `colorByAuthor` + * is always invoked per block too). + */ +function injectAllSentinels(text: string, marks: TaggedSpan[]): string { + if (marks.length === 0) return text; + const blocks = splitBlocksWithRanges(text); + if (blocks.length === 0) return text; + let out = ""; + let cursor = 0; + for (const b of blocks) { + out += text.slice(cursor, b.start); // preserve inter-block gaps (blank lines) verbatim + if (b.type === "prose") { + const blockMarks = marks.filter((m) => m.end > b.start && m.start < b.end); + out += blockMarks.length ? injectSentinels(b.raw, b.start, blockMarks) : b.raw; + } else { + out += b.raw; // INV-23: fences are never annotated + } + cursor = b.end; + } + out += text.slice(cursor); // trailing content after the last block + return out; +} + +/** + * Pure: markdown source -> sentinel-annotated source (authorship + change + + * proposal marks vs the F6/F7 baseline). Skips everything when annotations are + * disabled, or (the #48 rule) once the baseline was just PINNED with zero + * LANDED diff — pending F4 proposals are unaffected by that gate (matching the + * shipped preview's "pin→clean, proposals still show", #48/INV-33). + */ +export function annotateSource(src: string, inputs: AnnotationInputs): string { + if (!inputs.enabled) return src; + const proposals = inputs.proposals ?? []; + const baselineText = inputs.baselineText ?? src; // no baseline → no change-marks + const landedText = landedTextOf(src, proposals); + const pinnedZeroDiff = inputs.baselineReason === "pinned" && landedText === baselineText; + + const insSpans: TaggedSpan[] = []; + const deletions: { at: number; text: string }[] = []; + const appliedRanges: { start: number; end: number }[] = []; + + // (c) pending F4 proposals already sit applied in `src` (F12 optimistic + // apply, INV-48) — mark the applied range ins-claude (proposals are agent- + // authored, matching editorProposalController's own convention) and + // resurface the pre-apply original as a deletion at the same point. + for (const p of proposals) { + if (p.anchorStart === null || p.anchorEnd === null) continue; + appliedRanges.push({ start: p.anchorStart, end: p.anchorEnd }); + if (p.anchorEnd > p.anchorStart) insSpans.push({ start: p.anchorStart, end: p.anchorEnd, tag: "claude" }); + const original = p.original ?? p.replaced; + if (original) deletions.push({ at: p.anchorStart, text: original }); + } + + // (a)/(b) committed changes-since-baseline. Each hunk (wordEditHunks(src, + // baseline) — start/end in SRC coords, `replacement` = the baseline-side text + // for that span) yields (a) insertion marks from the F3 `spans` CLIPPED to the + // hunk's changed range — a hunk may straddle more than one author (e.g. two + // adjacent words typed by different authors with nothing committed between + // them), so spans are clipped individually, never the whole hunk tagged with + // one author — and (b) the baseline's dropped text reinserted at the hunk + // start. A hunk owned by an applied pending proposal is already marked above. + if (!pinnedZeroDiff && landedText !== baselineText) { + for (const h of wordEditHunks(src, baselineText)) { + if (appliedRanges.some((r) => h.start < r.end && h.end > r.start)) continue; + for (const s of inputs.spans) { + const start = Math.max(s.start, h.start); + const end = Math.min(s.end, h.end); + if (end > start) insSpans.push({ start, end, tag: s.author }); + } + if (h.replacement) deletions.push({ at: h.start, text: h.replacement }); + } + } + + if (insSpans.length === 0 && deletions.length === 0) return src; + + // Splice deletion reinsertions into the source high→low (so earlier offsets + // stay valid), shifting every mark recorded so far and appending a "del" mark + // over the freshly-inserted text. + const marks: TaggedSpan[] = insSpans.map((s) => ({ ...s })); + let text = src; + for (const d of [...deletions].sort((a, b) => b.at - a.at)) { + text = text.slice(0, d.at) + d.text + text.slice(d.at); + for (const m of marks) { + if (m.start >= d.at) m.start += d.text.length; + if (m.end >= d.at) m.end += d.text.length; + } + marks.push({ start: d.at, end: d.at + d.text.length, tag: "del" }); + } + + return injectAllSentinels(text, marks); +} + +/** + * markdown-it plugin factory: the host supplies `AnnotationInputs` per render + * (`host.inputsFor(env)`; `undefined` = no coediting context for this render — + * source passed through untouched). Installs (a) a core rule BEFORE `normalize` + * that swaps `state.src` for `annotateSource(...)`, and (b) a full-render + * wrapper (see module docstring) that turns surviving sentinel pairs into + * `cw-…` spans. Also teaches ```mermaid fences to render as `
`
+ * (Q4/Task 7 Step 2.6) via `options.highlight` — the same extension point the
+ * built-in preview's own fence rule already calls for syntax highlighting — so
+ * the bundled `media/preview-mermaid.js` previewScript has something to run
+ * mermaid over (the built-in preview does not render mermaid on its own).
+ */
+export function cowritingMarkdownItPlugin(
+  md: MarkdownIt,
+  host: { inputsFor(env: unknown): AnnotationInputs | undefined },
+): MarkdownIt {
+  md.core.ruler.before("normalize", "cowriting-annotate", (state) => {
+    const inputs = host.inputsFor(state.env);
+    if (!inputs) return;
+    state.src = annotateSource(state.src, inputs);
+  });
+
+  const baseRender = md.renderer.render.bind(md.renderer);
+  md.renderer.render = (tokens, options, env) => sentinelsToSpans(baseRender(tokens, options, env), "ins");
+
+  const baseHighlight = md.options.highlight;
+  md.options.highlight = (code, lang, attrs) => {
+    if (lang?.trim().toLowerCase() === "mermaid") {
+      return `
${escapeMermaidSource(code)}
`; + } + return baseHighlight?.(code, lang, attrs) ?? md.utils.escapeHtml(code); + }; + + return md; +} + +/** Minimal HTML-escape for a mermaid fence body (no markdown parsing — verbatim diagram source). */ +function escapeMermaidSource(source: string): string { + return source + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\n+$/, ""); +} diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 7c4c3a6..7a6316a 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -658,15 +658,33 @@ export interface AuthorSpan { author: AuthorKind; } +/** + * Task 7 (preview annotations, native-surfaces migration): the sentinel + * vocabulary generalizes beyond per-author spans to a plain "del" tag — a + * re-surfaced baseline deletion has no author variant (CSS carries one fixed + * `.cw-del`, the F10 vocabulary). `injectSentinels`/`sentinelsToSpans` are + * exported so `previewAnnotations.ts` can drive the SAME token-aware + * discipline (#33/#47) for its 3-way ins-claude/ins-human/del marks; + * `colorByAuthor` (F9/F10) keeps its narrower per-author call, unaffected. + */ +export type SentinelTag = AuthorKind | "del"; +export interface TaggedSpan { + start: number; + end: number; + tag: SentinelTag; +} + // Private-Use-Area sentinels (never appear in real content; markdown-it passes -// them through as plain text). Paired open/close per author. -const SENT = { +// them through as plain text). Paired open/close per tag. +const SENT: Record = { claude: { open: "", close: "" }, human: { open: "", close: "" }, -} as const; + del: { open: "", close: "" }, +}; +const SENT_TAGS = Object.keys(SENT) as SentinelTag[]; function isCloseSentinel(m: string): boolean { - return m === SENT.claude.close || m === SENT.human.close; + return SENT_TAGS.some((t) => SENT[t].close === m); } // Markdown emphasis / code delimiters whose RUNS must never be split by an @@ -712,8 +730,8 @@ function blockContentStart(raw: string): number { return 0; } -/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */ -function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string { +/** Inject paired sentinels into a block's raw text for the (tagged) spans clipped to it. */ +export function injectSentinels(raw: string, blockStart: number, spans: TaggedSpan[]): string { const inserts: { at: number; marker: string }[] = []; // Open sentinels must not precede block-level markdown markers (heading/list/ // blockquote syntax at position 0) — a PUA char before "## " prevents markdown-it @@ -723,8 +741,8 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): const lo = Math.max(minOpen, clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart))); const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart)); if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing - inserts.push({ at: lo, marker: SENT[s.author].open }); - inserts.push({ at: hi, marker: SENT[s.author].close }); + inserts.push({ at: lo, marker: SENT[s.tag].open }); + inserts.push({ at: hi, marker: SENT[s.tag].close }); } // Apply high offset → low so earlier offsets stay valid. At an equal offset // (one span's close == the next's open), apply opens BEFORE closes so the @@ -735,34 +753,34 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): return out; } -const SENTINEL_OF: Record = { - [SENT.claude.open]: { author: "claude", open: true }, - [SENT.claude.close]: { author: "claude", open: false }, - [SENT.human.open]: { author: "human", open: true }, - [SENT.human.close]: { author: "human", open: false }, -}; -const ALL_SENTINELS = new RegExp( - `[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`, - "g", -); +const SENTINEL_OF: Record = {}; +for (const tag of SENT_TAGS) { + SENTINEL_OF[SENT[tag].open] = { tag, open: true }; + SENTINEL_OF[SENT[tag].close] = { tag, open: false }; +} +const ALL_SENTINELS = new RegExp(`[${SENT_TAGS.map((t) => SENT[t].open + SENT[t].close).join("")}]`, "g"); /** - * #33: token-aware replacement of the rendered author sentinels with `cw-ins-*` - * spans. A naive global string-replace (the old approach) could emit a span that + * #33: token-aware replacement of the rendered sentinels with `cw-*` spans. A + * naive global string-replace (the old approach) could emit a span that * CROSSES an element boundary — `bold` — when a * boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and - * emits the author span only around TEXT runs, CLOSING it before any `` and + * emits the span only around TEXT runs, CLOSING it before any `` and * REOPENING it after, so a span is always well-nested within the inline elements * (one `` segment per text run). Tags are copied verbatim (with any stray - * sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic. + * sentinel stripped, so no Private-Use-Area char ever leaks). `kind` picks the + * author-class prefix (`cw-by-*`/`cw-ins-*`); the tag-less "del" mark (Task 7, + * no author variant) always renders the fixed `cw-del` regardless of `kind`. + * Pure, deterministic. */ -function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string { +export function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string { const out: string[] = []; - let current: AuthorKind | null = null; // which author region we're inside + let current: SentinelTag | null = null; // which region we're inside let spanOpen = false; // whether a is currently open in `out` + const classFor = (tag: SentinelTag): string => (tag === "del" ? "cw-del" : `cw-${kind}-${tag}`); const openSpan = () => { if (current && !spanOpen) { - out.push(``); + out.push(``); spanOpen = true; } }; @@ -776,7 +794,7 @@ function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string { const ch = html[i]; const sentinel = SENTINEL_OF[ch]; if (sentinel) { - if (sentinel.open) current = sentinel.author; // span opens lazily before the next text char + if (sentinel.open) current = sentinel.tag; // span opens lazily before the next text char else { closeSpan(); current = null; @@ -813,7 +831,11 @@ export function colorByAuthor( kind: "by" | "ins" = "by", ): string { const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length); - const injected = injectSentinels(raw, blockStart, overlapping); + const injected = injectSentinels( + raw, + blockStart, + overlapping.map((s) => ({ start: s.start, end: s.end, tag: s.author })), + ); return sentinelsToSpans(render(injected), kind); } diff --git a/test/e2e/suite/previewAnnotations.test.ts b/test/e2e/suite/previewAnnotations.test.ts new file mode 100644 index 0000000..dbdf403 --- /dev/null +++ b/test/e2e/suite/previewAnnotations.test.ts @@ -0,0 +1,79 @@ +import * as assert from "node:assert"; +import MarkdownIt from "markdown-it"; +import * as vscode from "vscode"; +import { cowritingMarkdownItPlugin } from "../../../src/previewAnnotations"; +import { activateApi, settle } from "./helpers"; + +// Task 7 (D3/D21, PUC-3): the built-in preview's DOM isn't queryable from a +// host E2E test (§6.8), so this asserts the pure transform's INPUTS/OUTPUTS +// through the real `CowritingApi` seams instead — the production +// `previewAnnotationHost.inputsFor` (not a hand-rolled reimplementation) fed +// through the real `cowritingMarkdownItPlugin` + a fresh markdown-it instance +// (mirroring the unit suite's own `render()` helper), so this exercises the +// ACTUAL registry/diffView/attribution/proposals/config wiring end to end. +suite("PUC-3 annotate-toggle (built-in Markdown preview annotations)", () => { + test("a machine-attributed change renders cw-ins-claude; disabling the setting passes the source through unchanged", async () => { + const api = await activateApi(); + const doc = await vscode.workspace.openTextDocument({ + language: "markdown", + content: "hello world\n", + }); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settle(); + + const start = doc.getText().indexOf("world"); + const ok = await vscode.commands.executeCommand("cowriting.applyAgentEdit", { + uri: doc.uri.toString(), + start, + end: start + "world".length, + newText: "brave new world", + model: "sonnet", + sessionId: "e2e-preview-annotations", + turnId: "turn-e2e-preview-annotations", + }); + assert.strictEqual(ok, true, "seam edit applies"); + await settle(); + + // Annotations ON (default): render the CURRENT buffer through the real + // markdown-it plugin + the production host — proves the full pipeline + // (core-rule swap → full-render sentinel walk) reaches a live doc. + const md = new MarkdownIt(); + cowritingMarkdownItPlugin(md, api.previewAnnotationHost); + const html = md.render(doc.getText()); + assert.ok(html.includes("cw-ins-claude"), `expected cw-ins-claude in:\n${html}`); + + // Toggle OFF: the config-gated `enabled` flag reaches `annotateSource` and + // the transform becomes the identity (no core-rule swap, no marks). + const config = vscode.workspace.getConfiguration("cowriting"); + await config.update("annotations", false, vscode.ConfigurationTarget.Global); + try { + const inputs = api.previewAnnotationHost.inputsFor(undefined); + assert.ok(inputs, "inputsFor still resolves a coedited doc while disabled"); + assert.strictEqual(inputs!.enabled, false); + const md2 = new MarkdownIt(); + cowritingMarkdownItPlugin(md2, api.previewAnnotationHost); + const plainMd = new MarkdownIt(); + assert.strictEqual(md2.render(doc.getText()), plainMd.render(doc.getText())); + } finally { + await config.update("annotations", true, vscode.ConfigurationTarget.Global); + } + }); + + test("cowriting.toggleAnnotations flips the setting", async () => { + const api = await activateApi(); + const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "toggle me\n" }); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settle(); + + const config = () => vscode.workspace.getConfiguration("cowriting"); + const before = config().get("annotations", true); + await vscode.commands.executeCommand("cowriting.toggleAnnotations"); + assert.strictEqual(config().get("annotations", true), !before); + // restore + await vscode.commands.executeCommand("cowriting.toggleAnnotations"); + assert.strictEqual(config().get("annotations", true), before); + void api; // keep the activated extension alive for the duration of the test + }); +}); diff --git a/test/previewAnnotations.test.ts b/test/previewAnnotations.test.ts new file mode 100644 index 0000000..0279fdd --- /dev/null +++ b/test/previewAnnotations.test.ts @@ -0,0 +1,46 @@ +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 + }); +}); -- 2.39.5 From 2170a0d282f3936b06f91fbb1f705cd974d11555 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 14:51:18 -0700 Subject: [PATCH 13/18] =?UTF-8?q?feat!:=20sunset=20the=20review-panel=20we?= =?UTF-8?q?bview=20=E2=80=94=20built-in=20preview=20+=20native=20diff=20ar?= =?UTF-8?q?e=20the=20review=20surfaces=20(D3/D17,=20spec=20=C2=A76.10);=20?= =?UTF-8?q?Keep/Reject=20lens=20copy=20(PUC-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes the last bespoke UI surface (TrackChangesPreviewController + its sealed webview client assets); every entry point re-points at native VS Code chrome per spec §5: the #41 right-click entries become cowriting.openReviewPreview (enter coediting if needed -> "Open Preview to the Side"), Ctrl+Alt+R/Cmd+Alt+R moves to cowriting.reviewChanges (native diff), and the F12 CodeLens per-proposal titles read "Keep"/"Reject" with a top-of-file "Keep all (N)"/"Reject all" pair once >=2 proposals are pending. EditFlow drops its own askClaude/askEditInstruction (the webview's only caller) and its now-unused constructor params. Coverage that lived only in the webview's test seams (renderHtmlFor, receiveMessage, isOpen, ...) moves to direct calls against the surviving controllers/pure renderReview (test/e2e/suite/helpers.ts gains a shared renderHtmlFor probe); a genuine gap (pending-proposal + unchanged-block rendering) is backfilled in test/previewAnnotations.test.ts. README's "how it works" is rewritten as the native-surface map; the superseded F6/F7/F9/ F10/F11 sections are kept as a marked historical record rather than deleted outright. 292 unit + 91 E2E green. Co-Authored-By: Claude Fable 5 --- README.md | 97 ++++- esbuild.mjs | 26 +- media/preview.css | 118 ------- media/preview.ts | 157 --------- package.json | 14 +- src/editFlow.ts | 113 +----- src/editorProposalController.ts | 26 +- src/extension.ts | 77 ++-- src/trackChangesPreview.ts | 391 --------------------- test/e2e/suite/authorship.test.ts | 19 +- test/e2e/suite/f10Review.test.ts | 310 ---------------- test/e2e/suite/f11Toolbar.test.ts | 102 +----- test/e2e/suite/f12Accept.test.ts | 21 +- test/e2e/suite/f12InlineDiff.test.ts | 24 +- test/e2e/suite/f12Reach.test.ts | 2 +- test/e2e/suite/f12Review.test.ts | 54 ++- test/e2e/suite/helpers.ts | 20 ++ test/e2e/suite/liveProgress.test.ts | 6 +- test/e2e/suite/reviewPanelMenu.test.ts | 119 +++++-- test/e2e/suite/s48PinClean.test.ts | 17 +- test/e2e/suite/trackChangesPreview.test.ts | 140 -------- test/e2e/suite/undoMarks.test.ts | 5 +- test/previewAnnotations.test.ts | 29 ++ 23 files changed, 412 insertions(+), 1475 deletions(-) delete mode 100644 media/preview.css delete mode 100644 media/preview.ts delete mode 100644 src/trackChangesPreview.ts delete mode 100644 test/e2e/suite/f10Review.test.ts delete mode 100644 test/e2e/suite/trackChangesPreview.test.ts diff --git a/README.md b/README.md index 1a4b979..4cfc315 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,59 @@ catalog (a pure, key-free SDK call) in a notification and the Features shipped so far: F2 region-anchored threads (Feature #4), F3 live human/Claude attribution (Feature #6), F4 propose/accept diff flow (Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view -toggle (Feature #17), F10 interactive review — **write left / review -right** (Feature #29), and F11 — the **preview toolbar as the primary -interaction surface** (Feature #43). +data layer (Feature #17), and the **native-surfaces migration** (the +"Coediting Markdown with a Machine" Solution Design) — the current UI, replacing +the bespoke F7/F10/F11 review webview with VS Code's own chrome end to end. See +**How it works** below for the current experience; the F7/F9/F10/F11 sections +further down are kept as a **historical record** of the superseded webview (each +now says so up top). + +## How it works + +There is **no bespoke panel**. Every surface below is native VS Code chrome — +the editor title bar, status bar, Comments gutter, source control, diff editor, +and the built-in Markdown preview: + +1. **Enter coediting.** Run **"✦ Coedit this Document with Claude"** (command + palette, editor title menu, or editor/explorer context menu) on a Markdown + document — it captures a **baseline** (a clean checkpoint: git HEAD if the + file is tracked and clean, otherwise a snapshot) to measure future changes + against. Until a document is entered, none of the surfaces below attach to + it. **"Stop editing with Claude"** reverses it. +2. **Title bar + status bar are the everyday entry points.** Once coedited, the + editor title bar carries **Review Changes**, **Ask Claude**, **Toggle + Annotations**, and (in snapshot mode) **Mark Changes as Reviewed**; a + `✦ Coediting · N changes` status-bar item shows the pending-change count and + opens the review when clicked — no need to find the Source Control pane. +3. **Ask Claude is comments-first.** **"Ask Claude"** (editor title, or select + text → right-click → "Ask Claude to Edit") opens a focused comment box on + the native **Comments API** — on your selection, or a top-anchored + whole-document thread with none selected. Claude replies in-thread and + offers an edit; accepting the offer turns it into one or more pending + proposals. +4. **Pending changes live in the buffer.** A proposal is **optimistically + applied** straight into the editor (green/blue insertion tint, a struck + deletion hint) with an inline **`✓ Keep` / `✗ Reject`** CodeLens above each + changed block — and, once ≥2 proposals are pending, a top-of-file + **`✓ Keep all (N)` / `✗ Reject all`** pair. Nothing is force-applied: a + proposal whose target text changed underneath it is skipped, never guessed. +5. **Native diff answers "what changed?"** **Review Changes** (editor title, or + `Ctrl+Alt+R` / `Cmd+Alt+R`) opens VS Code's own diff editor — baseline on the + left, your live document on the right — with the platform's inline/side-by-side + toggle, navigation, and accessibility for free. A Source Control gutter/viewlet + entry (quick-diff change bars, "Open Changes") is a bonus home for git users, + never the required path. +6. **The built-in Markdown preview is the annotated read.** "Open Preview to the + Side" (or right-click a markdown file/tab → **"Open Cowriting Review + Preview"**, `cowriting.openReviewPreview`) renders the document with + authorship/change coloring **inside VS Code's own preview** — green = human, + blue = Claude, strikethrough = deleted — toggled by **Toggle Annotations** + (command or the title-bar eye icon). No separate webview, no separate + persistence. + +Design: `vscode-cowriting-plugin-content/specs/coauthoring-native-surfaces.md` +(§5 UX Layout is the canonical source for this map). Migration plan: +[`docs/superpowers/plans/2026-07-01-native-surfaces-migration.md`](docs/superpowers/plans/2026-07-01-native-surfaces-migration.md). ## Architecture @@ -134,7 +184,16 @@ record per the contract; git push/pull is the transport, no re-homing ever. Design: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`. -## F6 — Diff-view toggle (Feature #17, #19) +## F6 — Diff-view toggle (Feature #17, #19) — SUPERSEDED + +> **Superseded.** The two-pane `Ctrl+Alt+D` toggle this section describes was +> deleted in #34; only the baseline **data layer** survived, now serving the +> native diff editor (`Ctrl+Alt+R` / "Review Changes" — How it works §5) and the +> built-in preview annotations (§6). The native-surfaces migration's Task 2 +> additionally **retired INV-18** (machine-landing auto-advance): a landed +> Claude edit stays a visible change until "Mark Changes as Reviewed" (the +> renamed "Pin Diff Baseline to Now"), it no longer auto-clears. Kept below as a +> historical record. **`Ctrl+Alt+D`** (the same chord on macOS — not `Cmd`; or **Cowriting: Toggle Diff View**) flips the focused document into a native `vscode.diff` against a @@ -166,7 +225,12 @@ instead of git archaeology. Design: `vscode-cowriting-plugin-content/specs/coauthoring-diff-view.md`. Live smoke: [`docs/MANUAL-SMOKE-F6.md`](docs/MANUAL-SMOKE-F6.md). -## F7 — Rendered track-changes preview (Feature #21) +## F7 — Rendered track-changes preview (Feature #21) — SUPERSEDED + +> **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. **`Ctrl+Alt+R`** (or **Cowriting: Open Track-Changes Preview**) opens a read-only webview **beside** a **Markdown** editor that renders the document and @@ -198,7 +262,12 @@ the manual smoke, not the sealed-sandbox E2E. Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`. Live smoke: [`docs/MANUAL-SMOKE-F7.md`](docs/MANUAL-SMOKE-F7.md). -## F9 — Authorship view in the preview (Feature ~#27) +## F9 — Authorship view in the preview (Feature ~#27) — SUPERSEDED + +> **Superseded (Task 8, native-surfaces migration).** The F7 webview this mode +> lived in was deleted; the built-in preview's annotations (How it works §6) +> always show authorship + change coloring together — there is no separate +> mode toggle. Kept below as a historical record. The rendered preview (F7) gains a second mode, switched by a `[ Track changes | Authorship ]` toggle in its header. **Authorship** mode re-renders the current @@ -239,7 +308,13 @@ a plain PR revert with zero data migration. Design: `vscode-cowriting-plugin-content/specs/coauthoring-out-of-workspace.md`. Live smoke: [`docs/MANUAL-SMOKE-F8.md`](docs/MANUAL-SMOKE-F8.md). -## F10 — Interactive review: write left / review right (Feature #29) +## F10 — Interactive review: write left / review right (Feature #29) — SUPERSEDED + +> **Superseded (Task 8, native-surfaces migration).** The editor is no longer +> "zero-annotation" — pending proposals now render optimistically applied +> in-buffer with `✓ Keep`/`✗ Reject` CodeLens (F12), and review happens across +> the native diff editor + built-in preview, not one dedicated webview. See +> **How it works** above. Kept below as a historical record. A clean, **zero-annotation editor** on the left; the rendered preview on the right as the **single interactive review surface**. The editor carries no @@ -266,7 +341,13 @@ they are no longer separate user surfaces. Design: `vscode-cowriting-plugin-content/specs/coauthoring-interactive-review.md`. Live smoke: [`docs/MANUAL-SMOKE-F10.md`](docs/MANUAL-SMOKE-F10.md). -## F11 — Preview toolbar as the primary interaction surface (Feature #43) +## F11 — Preview toolbar as the primary interaction surface (Feature #43) — SUPERSEDED + +> **Superseded (Task 8, native-surfaces migration).** The webview toolbar this +> section describes was deleted; its two controls moved to native chrome — +> **Pin baseline** is now **"Mark Changes as Reviewed"** in the editor title +> bar, and **Ask Claude** is the comments-first ask (How it works §3). Kept +> below as a historical record. The review preview's **header toolbar** becomes the cockpit for the inner loop. Beside the existing **Annotations** switch it gains two controls: diff --git a/esbuild.mjs b/esbuild.mjs index 762ec83..ca9a273 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -30,29 +30,15 @@ const liveTurnOptions = { logLevel: "info", }; -/** @type {import('esbuild').BuildOptions} */ -const previewOptions = { - entryPoints: ["media/preview.ts"], - outfile: "out/media/preview.js", - bundle: true, - // The webview is a browser context; mermaid is bundled IN (and ONLY in) this - // asset so it never bloats the extension-host bundle (the @cline/sdk size - // discipline). No externals — everything is shipped to the sealed webview. - platform: "browser", - format: "iife", - target: "es2020", - sourcemap: true, - logLevel: "info", -}; - /** @type {import('esbuild').BuildOptions} */ const previewMermaidOptions = { entryPoints: ["media/preview-mermaid.ts"], outfile: "out/media/preview-mermaid.js", bundle: true, // Task 7 (Q4): contributed via markdown.previewScripts into the BUILT-IN - // preview's webview — same browser/IIFE shape as previewOptions above, and - // the same reason mermaid is bundled here (never in the host bundle). + // preview's webview (a browser context) — mermaid is bundled IN (and ONLY + // in) this asset so it never bloats the extension-host bundle (the + // @cline/sdk size discipline). platform: "browser", format: "iife", target: "es2020", @@ -63,16 +49,14 @@ const previewMermaidOptions = { if (watch) { const ctx = await context(options); const ctxLive = await context(liveTurnOptions); - const ctxPreview = await context(previewOptions); const ctxPreviewMermaid = await context(previewMermaidOptions); - await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch(), ctxPreviewMermaid.watch()]); + await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreviewMermaid.watch()]); console.log("esbuild: watching…"); } else { await build(options); await build(liveTurnOptions); - await build(previewOptions); await build(previewMermaidOptions); console.log( - "esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js + out/media/preview-mermaid.js", + "esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview-mermaid.js", ); } diff --git a/media/preview.css b/media/preview.css deleted file mode 100644 index f706ff1..0000000 --- a/media/preview.css +++ /dev/null @@ -1,118 +0,0 @@ -/* F7 track-changes preview — theme-aware via VS Code webview CSS variables. */ -body { - font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); - color: var(--vscode-foreground); - background: var(--vscode-editor-background); - padding: 0 1.2rem 2rem; - line-height: 1.5; -} -#cw-header { - position: sticky; - top: 0; - background: var(--vscode-editor-background); - border-bottom: 1px solid var(--vscode-panel-border); - padding: 0.5rem 0; - font-size: 0.85em; - opacity: 0.85; - display: flex; - gap: 1rem; -} -/* Author-colored track changes — style = operation, color = author. - underline = inserted, strikethrough = removed; human green/red, Claude blue/purple. */ -#cw-summary .cw-add { color: #3fb950; } -#cw-summary .cw-del { color: #f85149; } -.cw-blk { position: relative; } - -/* base ins/del carry the operation; author classes carry the color */ -ins { text-decoration: none; } -del { text-decoration: line-through; opacity: 0.7; } - -.cw-ins-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; } -.cw-ins-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; } -.cw-ins-none { background: var(--vscode-diffEditor-insertedTextBackground, rgba(63,185,80,0.14)); text-decoration: none; } -.cw-del-human { background: rgba(248,81,73,0.11); text-decoration: line-through; text-decoration-color: #f85149; } -.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; } -.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; } - -/* Task 8 — overlap: if a future render path nests cw-ins-{A} inside cw-del-{B}, - both marks must show. cw-ins-* sets text-decoration:none which would otherwise - suppress the parent del's strikethrough. `inherit` restores the parent's - line-through while the child's border-bottom underline is unaffected. - The engine currently emits SIBLING ins/del (never nested) so this rule is a - forward defensive guarantee only. See task-8-report.md for details. */ -.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude { text-decoration: inherit; } - -/* whole-block ops: an added block is an insertion (its author runs are emitted as - cw-ins-* via colorByAuthor with kind="ins"); a standalone removed block is neutral - struck (adjacency heuristic fallback) */ -.cw-added { background: transparent; } -.cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.10)); text-decoration: line-through; opacity: 0.65; } -.cw-changed { outline: none; } - -#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; } -#cw-summary .cw-prop { opacity: 0.85; } -.cw-badge { - position: absolute; - top: 0; - right: 0; - font-size: 0.7em; - padding: 0 0.4em; - border-radius: 3px; - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); -} -.cw-error { - border: 1px solid var(--vscode-inputValidation-errorBorder); - background: var(--vscode-inputValidation-errorBackground); - color: var(--vscode-errorForeground); - padding: 0.3rem 0.6rem; - border-radius: 3px; - font-size: 0.85em; -} -pre.mermaid { text-align: center; background: transparent; } -pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); } - - -/* F11 — preview toolbar buttons (Pin baseline; adaptive Ask Claude). Theme-aware. */ -#cw-header button { - cursor: pointer; - font: inherit; - border: 1px solid var(--vscode-button-border, transparent); - border-radius: 3px; - padding: 0.1em 0.55em; - background: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); -} -#cw-header button:hover:not(:disabled) { background: var(--vscode-button-secondaryHoverBackground); } -#cw-header button:disabled { opacity: 0.5; cursor: default; } - -/* F10 interactive review — annotations toggle + ✓/✗ proposal blocks. */ -#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; } -.cw-proposal { - position: relative; - border-left: 3px solid var(--vscode-panel-border, #555); - background: color-mix(in srgb, var(--vscode-foreground) 5%, 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-btngroup { display: inline-flex; } -.cw-btngroup .cw-caret { border-left: none; padding: 0.1em 0.25em; } -.cw-actions .cw-accept { font-weight: 600; } -.cw-accept:hover, .cw-btngroup:has(.cw-accept) .cw-caret:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; } -.cw-reject:hover, .cw-btngroup:has(.cw-reject) .cw-caret:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; } - -/* F7.1 (#22) intra-diagram mermaid diff legend. */ -.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; } -.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; } -.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; } -.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; } -.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; } diff --git a/media/preview.ts b/media/preview.ts deleted file mode 100644 index f4769e2..0000000 --- a/media/preview.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * F7 preview webview client (sealed sandbox, INV-21). Receives annotated HTML - * from the extension host and swaps it in; runs mermaid over `.mermaid` blocks - * (mermaid needs a DOM, so it runs here, not in the host). Bundled by esbuild as - * a standalone IIFE → out/media/preview.js, so mermaid never enters the host - * bundle. No network, no LLM. - */ -import mermaid from "mermaid"; -// Imported so esbuild emits the sibling out/media/preview.css (the controller -// links it into the sealed shell via asWebviewUri). -import "./preview.css"; - -declare function acquireVsCodeApi(): { postMessage(m: unknown): void }; - -interface RenderMessage { - type: "render"; - mode: "on" | "off"; - html: string; - epoch?: string; - summary?: { added: number; removed: number; proposals: number }; - /** F11: false on a non-authorable doc → Pin + Ask-Claude controls disabled. */ - authorable?: boolean; -} - -const vscodeApi = acquireVsCodeApi(); -const body = document.getElementById("cw-body")!; -const header = document.getElementById("cw-epoch")!; -const summary = document.getElementById("cw-summary")!; -const legend = document.getElementById("cw-legend")!; -const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null; -const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null; -const askEl = document.getElementById("cw-ask") as HTMLButtonElement | null; -const acceptAllEl = document.getElementById("cw-acceptall") as HTMLButtonElement | null; - -// F10: the annotations on/off toggle. -annotationsEl?.addEventListener("change", () => { - vscodeApi.postMessage({ type: "setMode", mode: annotationsEl.checked ? "on" : "off" }); -}); - -// F11 (SLICE-1): Pin baseline — post intent; the host pins via the F6 store (INV-35). -pinEl?.addEventListener("click", () => { - vscodeApi.postMessage({ type: "pinBaseline" }); -}); - -// F11 (SLICE-4): the single adaptive Ask-Claude button. Its label flips on -// `selectionchange` (Edit Selection when live text is selected in the preview, -// Edit Document otherwise), and a click resolves the selection to a SOURCE range -// via the nearest `data-src` ancestors (INV-36) — the webview's sole mapping -// duty. A selection that resolves to no live block falls back to document scope. - -/** Walk up from a DOM node to the nearest block carrying data-src offsets (INV-36). */ -function nearestSrc(node: Node | null): HTMLElement | null { - let el: HTMLElement | null = node instanceof HTMLElement ? node : (node?.parentElement ?? null); - while (el && el !== body) { - if (el.dataset.srcStart !== undefined && el.dataset.srcEnd !== undefined) return el; - el = el.parentElement; - } - return null; -} - -/** The source [start,end) union of the live blocks a non-empty body selection touches, or null. */ -function selectionSrcRange(): { start: number; end: number } | null { - const sel = window.getSelection(); - if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null; - const ends = [nearestSrc(sel.anchorNode), nearestSrc(sel.focusNode)].filter( - (e): e is HTMLElement => e !== null, - ); - if (ends.length === 0) return null; // selection touches no live-source block - const starts = ends.map((e) => Number(e.dataset.srcStart)); - const stops = ends.map((e) => Number(e.dataset.srcEnd)); - return { start: Math.min(...starts), end: Math.max(...stops) }; -} - -function updateAskLabel(): void { - if (!askEl) return; - askEl.textContent = selectionSrcRange() - ? "✦ Ask Claude to Edit Selection" - : "✦ Ask Claude to Edit Document"; -} - -document.addEventListener("selectionchange", updateAskLabel); - -askEl?.addEventListener("click", () => { - const range = selectionSrcRange(); - if (range) { - vscodeApi.postMessage({ type: "askClaude", scope: "selection", start: range.start, end: range.end }); - } else { - vscodeApi.postMessage({ type: "askClaude", scope: "document" }); - } -}); - -// #46 (INV-42): Accept all — batch-accept every pending proposal (intent only). -acceptAllEl?.addEventListener("click", () => { - vscodeApi.postMessage({ type: "acceptAll" }); -}); - -// F12/#64: Accept/Reject (+ caret → accept-all/reject-all) on a proposal block. -body.addEventListener("click", (e) => { - const btn = (e.target as HTMLElement)?.closest(".cw-actions button"); - if (!btn) return; - const id = btn.closest(".cw-proposal")?.dataset.proposalId; - const action = btn.dataset.action; - if (action === "acceptAll") return void vscodeApi.postMessage({ type: "acceptAll" }); - if (action === "rejectAll") return void vscodeApi.postMessage({ type: "rejectAll" }); - if (id && (action === "accept" || action === "reject")) { - vscodeApi.postMessage({ type: action, proposalId: id }); - } -}); - -function themeFor(): "dark" | "default" { - return document.body.classList.contains("vscode-dark") || - document.body.classList.contains("vscode-high-contrast") - ? "dark" - : "default"; -} - -async function renderMermaid(): Promise { - const nodes = Array.from(body.querySelectorAll("pre.mermaid")); - if (nodes.length === 0) return; - mermaid.initialize({ startOnLoad: false, theme: themeFor(), securityLevel: "strict" }); - try { - await mermaid.run({ nodes }); - } catch { - // mermaid.run already marks failed nodes; ensure a visible chip per failure. - for (const n of nodes) { - if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true"); - } - } -} - -window.addEventListener("message", (event: MessageEvent) => { - const msg = event.data; - if (msg?.type !== "render") return; - body.innerHTML = msg.html; - updateAskLabel(); // new content clears any selection → reset the adaptive label - // F11 (PUC-1/7): disable edit controls on a non-authorable doc (reading stays on). - const authorable = msg.authorable !== false; - if (pinEl) pinEl.disabled = !authorable; - if (askEl) askEl.disabled = !authorable; - const on = msg.mode === "on"; - if (annotationsEl) annotationsEl.checked = on; - // #46: Accept all shows only with ≥2 pending proposals, on an authorable doc, - // in the annotated (on) state — a single proposal is just a ✓ in place. - if (acceptAllEl) acceptAllEl.hidden = !on || !authorable || (msg.summary?.proposals ?? 0) < 2; - // Off-state is a clean preview: hide the review chrome. - header.hidden = !on; - summary.hidden = !on; - legend.hidden = true; - if (on) { - header.textContent = `Review since ${msg.epoch ?? ""}`; - summary.innerHTML = - `+${msg.summary?.added ?? 0} ` + - `−${msg.summary?.removed ?? 0} ` + - `${msg.summary?.proposals ?? 0} proposal${(msg.summary?.proposals ?? 0) === 1 ? "" : "s"}`; - } - void renderMermaid(); -}); diff --git a/package.json b/package.json index 5f927ce..76f3d68 100644 --- a/package.json +++ b/package.json @@ -109,8 +109,8 @@ "icon": "$(git-compare)" }, { - "command": "cowriting.showTrackChangesPreview", - "title": "Open Cowriting Review Panel", + "command": "cowriting.openReviewPreview", + "title": "Open Cowriting Review Preview", "category": "Cowriting" }, { @@ -252,7 +252,7 @@ "group": "navigation@4" }, { - "command": "cowriting.showTrackChangesPreview", + "command": "cowriting.openReviewPreview", "when": "editorLangId == markdown", "group": "navigation@9" } @@ -264,14 +264,14 @@ "group": "1_cowriting@1" }, { - "command": "cowriting.showTrackChangesPreview", + "command": "cowriting.openReviewPreview", "when": "resourceLangId == markdown", "group": "1_cowriting@3" } ], "explorer/context": [ { - "command": "cowriting.showTrackChangesPreview", + "command": "cowriting.openReviewPreview", "when": "resourceLangId == markdown", "group": "navigation@9" } @@ -330,10 +330,10 @@ }, "keybindings": [ { - "command": "cowriting.showTrackChangesPreview", + "command": "cowriting.reviewChanges", "key": "ctrl+alt+r", "mac": "cmd+alt+r", - "when": "editorLangId == markdown" + "when": "editorLangId == markdown && cowriting.isCoediting" }, { "command": "cowriting.edit", diff --git a/src/editFlow.ts b/src/editFlow.ts index 9c18d29..5b83273 100644 --- a/src/editFlow.ts +++ b/src/editFlow.ts @@ -1,28 +1,22 @@ /** * EditFlow — the host edit-turn flow (F11/F12), extracted from the review * webview controller ahead of its sunset (native-surfaces migration, spec - * §6.10). Owns the instruction prompt + progress-wrapped "ask Claude" gesture - * (INV-10 gate, INV-8 host-only LLM surface), and the low-level turn→proposal(s) - * cut (`runEditAndPropose`, INV-39/40). Never mutates the document directly — - * every turn lands as one or more F4 proposals via `ProposalController.propose` - * (INV-10). `runEditAndPropose` is reachable from the review webview (via this - * class's `askClaude`) and, from Task 6 on, the thread controller - * (`ThreadController.runMakeEdit`) — vscode-API-only, no webview state. The - * `cowriting.editDocument` command lives in extension.ts (Finding-1 fix) so it - * can hand off to `ThreadController.askClaude()`. + * §6.10). Owns the low-level turn→proposal(s) cut (`runEditAndPropose`, + * INV-39/40): never mutates the document directly — every turn lands as one or + * more F4 proposals via `ProposalController.propose` (INV-10). Reachable, from + * Task 6 on, from the thread controller (`ThreadController.runMakeEdit`) — + * vscode-API-only, no webview state. The `cowriting.editDocument` command lives + * in extension.ts (Finding-1 fix) so it can hand off to + * `ThreadController.askClaude()`. Task 8 deleted this class's own + * `askClaude`/`askEditInstruction` (the review webview's toolbar ask) once + * their only caller — the webview — was sunset alongside them. */ import * as vscode from "vscode"; import type { ProposalController } from "./proposalController"; -import type { AttributionController } from "./attributionController"; -import type { LiveProgressUi } from "./liveProgressUi"; -import type { CoeditingRegistry } from "./coeditingRegistry"; import { diffToBlockHunks } from "./trackChangesModel"; import { buildFingerprint } from "./anchorer"; import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; -/** The exact warning copy for every gated edit gesture (INV-10). */ -const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first."; - /** * F11: a host edit turn (selection/document text + instruction → rewrite). * Injectable for tests. #60: accepts optional turn options (onProgress/signal); @@ -42,100 +36,13 @@ export class EditFlow implements vscode.Disposable { const { runEditTurn } = await import("./liveTurn"); return runEditTurn(instruction, text, opts); }; - /** - * LEGACY (Task 6, spec §6.10): the instruction prompt used to be the - * multi-line split-below input webview (`editInstructionInput.ts`, deleted). - * The real interactive ask is now ThreadController.askClaude — a focused - * comment box (D19); `cowriting.editSelection` routes there directly. This - * field's only remaining production caller is the `cowriting.editDocument` - * command below (via `askClaude`), and the review-webview's toolbar ask — - * both die fully in Task 8. Rejects by default so any surviving real call - * fails loudly instead of silently invoking a webview that no longer - * exists; host E2E stub this field directly (mirrors `editTurn`). - */ - askEditInstruction: (header: string) => Promise = (header) => - Promise.reject( - new Error(`Cowriting: the "${header}" instruction input was removed — use ✦ Ask Claude instead.`), - ); /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ private turnSeq = 0; private nextTurnSeq(): number { return ++this.turnSeq; } - constructor( - private readonly proposals: ProposalController, - private readonly attribution: AttributionController, - private readonly liveProgressUi: LiveProgressUi, - private readonly registry: CoeditingRegistry, - ) { - // Code-review fix (Finding 1, session native-surfaces-exec): the - // `cowriting.editDocument` command used to be registered here, calling - // `this.askClaude(doc, {kind:"document"})` — which prompts via the (now - // rejecting-stub) `askEditInstruction`, a silent dead end. That command is now - // registered in extension.ts, AFTER ThreadController exists, and hands off to - // `ThreadController.askClaude()` (the comments-first ask, D19) instead. This - // class's `askClaude`/`askEditInstruction` remain: the review webview's - // toolbar ask (`trackChangesPreview.ts`) still delegates to them, slated for - // deletion alongside that webview in Task 8. - } - - /** - * F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret - * surface out of the sealed webview, INV-8/35), run the edit turn, and surface - * the result as F4 proposal(s). UI wrapper around `runEditAndPropose`. As of the - * Finding-1 fix, its only production caller is the review webview's `askClaude` - * toolbar intent (either scope) — `cowriting.editDocument`/`cowriting.editSelection` - * now delegate to `ThreadController.askClaude()` instead (D19). Both die together - * in Task 8 when the webview is sunset. - */ - async askClaude(document: vscode.TextDocument, target: EditTarget): Promise { - // INV-10: the choke point for the webview's askClaude toolbar intent (either - // scope) — a non-entered doc gets the warning, not a turn. - if (!this.registry.isCoediting(document.uri)) { - void vscode.window.showWarningMessage(NOT_COEDITING_WARNING); - return; - } - // Both scopes use the same multi-line split-below webview box; only the header - // (and the downstream proposal logic) differs. For a selection the document - // above keeps the selection highlighted while the box is open. - const header = - target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection"; - const instruction = await this.askEditInstruction(header); - if (!instruction) return; - 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) { - // #60 (INV-47): a user cancel proposes nothing (the benign empty path). - 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}`); - } - } + constructor(private readonly proposals: ProposalController) {} /** * F11/F12 (INV-35/39): run one host edit turn and record the result as F4 diff --git a/src/editorProposalController.ts b/src/editorProposalController.ts index b9489a3..379f4d4 100644 --- a/src/editorProposalController.ts +++ b/src/editorProposalController.ts @@ -216,19 +216,33 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL } } - /** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */ + /** + * CodeLensProvider (Task 8, PUC-2 wording): a `✓ Keep` / `✗ Reject` pair above + * each applied block, plus — when ≥2 proposals are pending on the doc — a + * top-of-file `✓ Keep all (N)` / `✗ Reject all` pair routed directly at the + * batch commands (no QuickPick detour for the common "review is done" case). + */ provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { if (document.languageId !== "markdown") return []; if (!this.registry.isCoediting(document.uri)) return []; const key = this.proposals.keyFor(document); + const applied = this.proposals + .listProposals(document) + .filter((v) => v.anchorStart !== null && this.proposals.isApplied(key, v.id)); const lenses: vscode.CodeLens[] = []; - for (const v of this.proposals.listProposals(document)) { - if (v.anchorStart === null || !this.proposals.isApplied(key, v.id)) continue; - const pos = document.positionAt(v.anchorStart); + if (applied.length >= 2) { + const top = new vscode.Range(0, 0, 0, 0); + lenses.push( + new vscode.CodeLens(top, { title: `✓ Keep all (${applied.length})`, command: "cowriting.acceptAllProposals" }), + new vscode.CodeLens(top, { title: "✗ Reject all", command: "cowriting.rejectAllProposals" }), + ); + } + for (const v of applied) { + const pos = document.positionAt(v.anchorStart!); const line = new vscode.Range(pos.line, 0, pos.line, 0); lenses.push( - new vscode.CodeLens(line, { title: "Accept ▾", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }), - new vscode.CodeLens(line, { title: "Reject ▾", command: "cowriting.proposalRejectMenu", arguments: [v.id] }), + new vscode.CodeLens(line, { title: "✓ Keep", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }), + new vscode.CodeLens(line, { title: "✗ Reject", command: "cowriting.proposalRejectMenu", arguments: [v.id] }), ); } return lenses; diff --git a/src/extension.ts b/src/extension.ts index c9877d9..591b484 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,7 +11,6 @@ import { GlobalSidecarStore } from "./globalSidecarStore"; import { SidecarRouter } from "./sidecarRouter"; import { DiffViewController } from "./diffViewController"; import { GitBaselineAdapter } from "./gitBaseline"; -import { TrackChangesPreviewController } from "./trackChangesPreview"; import { EditFlow } from "./editFlow"; import { LiveProgressUi } from "./liveProgressUi"; import { EditorProposalController } from "./editorProposalController"; @@ -29,7 +28,6 @@ export interface CowritingApi { proposalController: ProposalController; versionGuard: VersionGuard; diffViewController: DiffViewController; - trackChangesPreviewController: TrackChangesPreviewController; editFlow: EditFlow; sidecarRouter: SidecarRouter; liveProgressUi: LiveProgressUi; @@ -160,8 +158,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry); context.subscriptions.push(attributionController); - // --- F4: propose/accept (Feature #12) — constructed before the preview so F10 - // can route ✓/✗ through it --- + // --- F4: propose/accept (Feature #12) --- const proposalController = new ProposalController( sidecarRouter, attributionController, @@ -174,10 +171,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef // --- F11/F12 (native-surfaces migration, spec §6.10): the edit flow — the // `cowriting.editDocument` command, the instruction-prompt/progress-wrapped // "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut - // (`runEditAndPropose`, INV-39/40). Constructed BEFORE the review preview (which - // delegates to it) and BEFORE the thread controller, which now consumes it too - // (Task 6: the comment-loop's "make this edit" offer calls runEditAndPropose). --- - const editFlow = new EditFlow(proposalController, attributionController, liveProgressUi, coeditingRegistry); + // (`runEditAndPropose`, INV-39/40). Constructed BEFORE the thread controller, + // which consumes it too (Task 6: the comment-loop's "make this edit" offer + // calls runEditAndPropose). --- + const editFlow = new EditFlow(proposalController); context.subscriptions.push(editFlow); // --- F2/F10 (Task 6, D19/D10/D8): comments-first ask + the comment→reply→ @@ -186,20 +183,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry, editFlow, liveProgressUi); context.subscriptions.push(threadController); - // --- F7/F10: the review preview is the single interactive review surface --- - // Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline, - // INV-20). Constructed AFTER attribution (reads F3 spans), proposals (routes - // F4 accept/reject from the webview ✓/✗), and editFlow (delegates the webview's - // askClaude toolbar intent to it). - const trackChangesPreviewController = new TrackChangesPreviewController( - diffViewController, - context.extensionUri, - attributionController, - proposalController, - editFlow, - ); - context.subscriptions.push(trackChangesPreviewController); - // --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer, // decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). --- const editorProposalController = new EditorProposalController( @@ -211,10 +194,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef context.subscriptions.push(editorProposalController); // #46 (INV-42): accept every pending proposal on the active doc in one gesture - // (also reachable from the preview toolbar's "Accept all" button, which routes - // through its own webview-facing acceptAll). Reuses the batched F4 seam + - // reports applied-vs-skipped (native-surfaces migration: routes directly to - // ProposalController, no preview-controller indirection). + // (also reachable from the top-of-file "Keep all (N)" CodeLens, Task 8 PUC-2). + // Reuses the batched F4 seam + reports applied-vs-skipped, routing directly to + // ProposalController (native-surfaces migration: no preview-controller + // indirection). context.subscriptions.push( vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => { const doc = vscode.window.activeTextEditor?.document; @@ -223,7 +206,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef return; } const { applied, skipped } = await proposalController.acceptAllProposals(doc); - trackChangesPreviewController.refresh(doc); if (applied === 0 && skipped === 0) return; const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : ""; void vscode.window.showInformationMessage( @@ -242,7 +224,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef return; } const { reverted } = await proposalController.rejectAll(doc); - trackChangesPreviewController.refresh(doc); if (reverted > 0) { void vscode.window.showInformationMessage( `Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`, @@ -333,11 +314,13 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ); // Code-review fix (Finding 1, session native-surfaces-exec): `cowriting.editDocument` - // used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompts via - // `askEditInstruction` — a rejecting stub since Task 6 sunset the input webview - // (spec §6.10). Reached with no selection (the common case for `cowriting.edit`), - // that was a SILENT dead end: the reject fired before EditFlow.askClaude's own try/ - // catch and was `void`'d, so no toast, no thread, nothing. This command now resolves + // used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompted via + // `askEditInstruction` — a rejecting stub after Task 6 sunset the input webview + // (spec §6.10; both `askClaude`/`askEditInstruction` were deleted from EditFlow in + // Task 8 once their only caller, the review webview, died too). Reached with no + // selection (the common case for `cowriting.edit`), that was a SILENT dead end: the + // reject fired before EditFlow.askClaude's own try/catch and was `void`'d, so no + // toast, no thread, nothing. This command now resolves // its target document exactly as before (a tab's clicked URI, falling back to the // active editor), focuses it, and hands off to the SAME comments-first ask as // `cowriting.editSelection` — `ThreadController.askClaude()` top-anchors a @@ -386,6 +369,33 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef }), ); + // Task 8 (native-surfaces migration, spec §6.10): the minimal gateway replacing + // the sunset review-webview's #41 right-click entries. Resolves the clicked + // doc (or falls back to the active editor, mirroring editDocument/#41), enters + // coediting if not already (so a fresh doc gets a baseline to review against — + // no-op if already entered or not authorable), then hands off to VS Code's + // OWN "Open Preview to the Side" — the built-in preview IS the review surface + // now (Task 7's annotations render inside it). + context.subscriptions.push( + vscode.commands.registerCommand("cowriting.openReviewPreview", async (uri?: vscode.Uri) => { + const doc = uri + ? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ?? + (await vscode.workspace.openTextDocument(uri)) + : vscode.window.activeTextEditor?.document; + if (!doc || doc.languageId !== "markdown") { + void vscode.window.showWarningMessage("Cowriting: open a Markdown document to review it."); + return; + } + if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) { + await vscode.window.showTextDocument(doc); + } + if (!coeditingRegistry.isCoediting(doc.uri) && isAuthorable(doc.uri.scheme)) { + await vscode.commands.executeCommand("cowriting.coeditDocument"); + } + await vscode.commands.executeCommand("markdown.showPreviewToSide", doc.uri); + }), + ); + // Render threads + attributions for already-open editors, and on future opens. // INV-10: gated on the registry too — belt-and-suspenders with the per-controller // gates (renderAll/loadAll already early-return), but keeps the intent explicit @@ -456,7 +466,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef proposalController, versionGuard, diffViewController, - trackChangesPreviewController, editFlow, sidecarRouter, liveProgressUi, diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts deleted file mode 100644 index 9c1b1e5..0000000 --- a/src/trackChangesPreview.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** - * TrackChangesPreviewController — F7 vscode layer (spec §6.2/§6.4). Owns one - * sealed webview panel per markdown document, beside the source editor. On open / - * debounced edit / F6 baseline-epoch change it reads the baseline (from the - * reused DiffViewController) + the live buffer, runs the pure render engine, and - * posts the HTML. Pure read-only: never mutates the document, sidecar, or - * baseline (INV-20). The webview is sealed: local assets only, strict CSP, - * per-load nonce, no network (INV-21). - */ -import * as path from "node:path"; -import { randomBytes } from "node:crypto"; -import * as vscode from "vscode"; -import type { DiffViewController } from "./diffViewController"; -import type { AttributionController } from "./attributionController"; -import type { ProposalController } from "./proposalController"; -import { renderReview, renderPlain, diffBlocks, landedTextOf, type BlockOp } from "./trackChangesModel"; -import { isAuthorable } from "./workspacePath"; -import type { EditFlow, EditTarget } from "./editFlow"; - -const VIEW_TYPE = "cowriting.trackChangesPreview"; -const DEBOUNCE_MS = 150; - -/** - * Inbound webview→host messages (intent only — the sealed webview never mutates, - * INV-21/35). F10 carried the annotations toggle + ✓/✗ proposal decisions; F11 - * adds the toolbar intents (pin baseline / ask Claude). - */ -type ToolbarMsg = - | { type: "setMode"; mode: "on" | "off" } - | { type: "accept"; proposalId: string } - | { type: "reject"; proposalId: string } - | { type: "pinBaseline" } - | { type: "askClaude"; scope: "document" } - | { type: "askClaude"; scope: "selection"; start: number; end: number } - | { type: "acceptAll" } - | { type: "rejectAll" }; - -export class TrackChangesPreviewController implements vscode.Disposable { - private readonly disposables: vscode.Disposable[] = []; - private readonly panels = new Map(); - private readonly lastModel = new Map(); - private readonly debounces = new Map(); - /** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */ - private readonly mode = new Map(); - /** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */ - private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88); - - constructor( - private readonly diffView: DiffViewController, - private readonly extensionUri: vscode.Uri, - private readonly attribution: AttributionController, - private readonly proposals: ProposalController, - private readonly editFlow: EditFlow, - ) { - this.disposables.push( - // F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri; - // the palette / keybinding pass nothing → fall back to the active editor. - // #41: the explorer/tab right-click also passes the clicked Uri, which may - // not be an open document yet — open it so we preview the clicked file, not - // whatever happens to be the active editor. - vscode.commands.registerCommand("cowriting.showTrackChangesPreview", async (uri?: vscode.Uri) => { - if (uri) { - const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()); - this.show(open ?? (await vscode.workspace.openTextDocument(uri))); - return; - } - 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); - }), - this.statusItem, - ); - this.statusItem.command = "cowriting.showTrackChangesPreview"; - } - - private isMarkdown(document: vscode.TextDocument): boolean { - return document.languageId === "markdown"; - } - - /** Open or reveal the preview for a markdown document (PUC-1). */ - show(document: vscode.TextDocument | undefined): void { - if (!document || !this.isMarkdown(document)) { - void vscode.window.showWarningMessage( - "Cowriting: open a Markdown document to use the track-changes preview (F6 covers other files).", - ); - return; - } - const key = document.uri.toString(); - const existing = this.panels.get(key); - if (existing) { - existing.reveal(vscode.ViewColumn.Beside); - this.refresh(document); - return; - } - const name = path.basename(document.uri.path) || "untitled"; - const panel = vscode.window.createWebviewPanel( - VIEW_TYPE, - `Review: ${name}`, - { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, - { - enableScripts: true, - retainContextWhenHidden: false, - localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "out", "media")], - }, - ); - panel.webview.html = this.shellHtml(panel.webview); - panel.onDidDispose( - () => { - this.panels.delete(key); - this.lastModel.delete(key); - this.mode.delete(key); - // A panel is gone: re-show the off-panel indicator if proposals remain. - this.updateStatus(key); - }, - null, - this.disposables, - ); - // F10/F11: the webview posts the annotations toggle, ✓/✗ proposal decisions, - // and (F11) the toolbar intents (pin baseline / ask Claude) back to the host. - panel.webview.onDidReceiveMessage( - (m: ToolbarMsg) => this.handleWebviewMessage(document, m), - null, - this.disposables, - ); - this.panels.set(key, panel); - // A panel is now open for this doc — the off-panel indicator is redundant. - this.hideStatus(); - this.refresh(document); - } - - /** - * Route an inbound webview intent through the existing seams (INV-35): the - * annotations toggle + ✓/✗ proposal decisions (F10) and the toolbar gestures - * (F11). Pin targets the PREVIEWED document (`DiffViewController.pin`), not the - * active editor — the preview knows its bound doc (§6.7). - */ - private handleWebviewMessage(document: vscode.TextDocument, m: ToolbarMsg): void { - const key = document.uri.toString(); - 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.proposals.keyFor(document), m.proposalId) - .then(() => this.refresh(document)); - } else if (m?.type === "reject" && m.proposalId) { - void this.proposals.rejectByIdInPlace(this.proposals.keyFor(document), m.proposalId).then(() => this.refresh(document)); - } else if (m?.type === "pinBaseline") { - // F6 baseline store re-render arrives via the onDidChangeBaseline subscription. - this.diffView.pin(document); - } else if (m?.type === "askClaude") { - const target: EditTarget = - m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" }; - void this.editFlow.askClaude(document, target); - } else if (m?.type === "acceptAll") { - // #46 (INV-42): batch-accept every pending proposal on this doc, then report. - void this.acceptAll(document); - } else if (m?.type === "rejectAll") { - void this.rejectAll(document); - } - } - - /** - * #46 (INV-42): apply every pending proposal on the document through the F4 - * accept seam (orphan-skip) and report the applied-vs-skipped tally. No - * confirmation dialog — VS Code undo restores (parity with single accept). - * Public so the `cowriting.acceptAllProposals` command can reach it for the - * active doc (not only the webview button). - */ - async acceptAll(document: vscode.TextDocument): Promise { - const { applied, skipped } = await this.proposals.acceptAllProposals(document); - this.refresh(document); - if (applied === 0 && skipped === 0) return; - const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : ""; - void vscode.window.showInformationMessage( - `Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`, - ); - } - - /** #64 (INV-53): revert every pending proposal on the document; report the count. */ - async rejectAll(document: vscode.TextDocument): Promise { - const { reverted } = await this.proposals.rejectAll(document); - this.refresh(document); - if (reverted > 0) { - void vscode.window.showInformationMessage( - `Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`, - ); - } - } - - private onEdit(document: vscode.TextDocument): void { - const key = document.uri.toString(); - if (!this.panels.has(key)) return; - const pending = this.debounces.get(key); - if (pending) clearTimeout(pending); - this.debounces.set( - key, - setTimeout(() => { - this.debounces.delete(key); - this.refresh(document); - }, DEBOUNCE_MS), - ); - } - - private refreshByUri(uri: string): void { - const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); - if (doc) this.refresh(doc); - } - - /** Recompute the model + post HTML to the panel for its current mode (no-op if no panel). */ - refresh(document: vscode.TextDocument): void { - const key = document.uri.toString(); - const panel = this.panels.get(key); - if (!panel) return; - const 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); - // F11 (PUC-1/7): edit controls are inert on a non-authorable doc (reading stays allowed). - const authorable = isAuthorable(document.uri.scheme); - if (mode === "off") { - void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current), authorable }); - return; - } - const spans = this.attribution.spansFor(document); - const proposals = this.proposals.listProposals(document); - // F12/#64 (INV-50): count added/removed against the LANDED text (current minus - // pending proposals), matching the body — a pending change shows once, as a - // proposal, and is not also tallied as a landed add/remove. - const landedOps = diffBlocks(baselineText, landedTextOf(current, proposals)); - const summary = { - added: landedOps.filter((o) => o.kind === "added").length, - removed: landedOps.filter((o) => o.kind === "removed").length, - proposals: proposals.length, - }; - void panel.webview.postMessage({ - type: "render", - mode, - html: renderReview(baselineText, current, spans, proposals, { pinned: baseline?.reason === "pinned" }), - epoch: this.epochLabel(baseline), - summary, - authorable, - }); - } - - /** F10 (PUC-6): off-panel proposal indicator on the active doc. Hidden when a panel is open. */ - private updateStatus(uri: string): void { - const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); - if (!doc) { - this.hideStatus(); - return; - } - const n = this.proposals.listProposals(doc).length; - if (n === 0 || this.panels.has(uri)) { - this.hideStatus(); - 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(); - } - - /** - * Hide the off-panel indicator AND clear its text, so the `statusText()` seam - * is honest: a hidden indicator reports `undefined` (not its stale last value). - */ - private hideStatus(): void { - this.statusItem.text = ""; - this.statusItem.hide(); - } - - private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string { - if (!baseline) return "not coediting (no baseline yet)"; - const time = new Date(baseline.capturedAt).toLocaleTimeString(); - switch (baseline.reason) { - case "head": - return `HEAD ${time}`; - case "pinned": - return `reviewed ${time}`; - default: - return `entered ${time}`; - } - } - - private shellHtml(webview: vscode.Webview): string { - const nonce = randomBytes(16).toString("base64"); - const scriptUri = webview.asWebviewUri( - vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.js"), - ); - const styleUri = webview.asWebviewUri( - vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.css"), - ); - // Sealed CSP (INV-21): no network. 'unsafe-inline' style is required only for - // mermaid's dynamically injected