feat!: sunset the review-panel webview — built-in preview + native diff are the review surfaces (D3/D17, spec §6.10); Keep/Reject lens copy (PUC-2)

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 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 14:51:18 -07:00
parent 17fc01e8d8
commit 2170a0d282
23 changed files with 412 additions and 1475 deletions
+10 -103
View File
@@ -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<string | undefined> = (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<void> {
// 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
+20 -6
View File
@@ -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;
+43 -34
View File
@@ -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,
-391
View File
@@ -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 webviewhost 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<string, vscode.WebviewPanel>();
private readonly lastModel = new Map<string, BlockOp[]>();
private readonly debounces = new Map<string, NodeJS.Timeout>();
/** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */
private readonly mode = new Map<string, "on" | "off">();
/** 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<void> {
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<void> {
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 <style> tags; scripts are nonce-gated and
// strictly local (no remote/CDN script source).
const csp =
`default-src 'none'; ` +
`img-src ${webview.cspSource} data:; ` +
`font-src ${webview.cspSource}; ` +
`style-src ${webview.cspSource} 'unsafe-inline'; ` +
`script-src 'nonce-${nonce}';`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<link href="${styleUri}" rel="stylesheet" />
<title>Track changes</title>
</head>
<body>
<div id="cw-header">
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
<button id="cw-pin" type="button" title="Pin the review baseline to now (clears the change-marks)"> Pin baseline</button>
<button id="cw-ask" type="button" title="Ask Claude to edit (the selection if any, else the whole document)"> Ask Claude to Edit Document</button>
<button id="cw-acceptall" type="button" hidden title="Accept every pending Claude proposal on this document"> Accept all</button>
<span id="cw-epoch">Review</span>
<span id="cw-summary"></span>
<span id="cw-legend"></span>
</div>
<div id="cw-body"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
// ---- test seam (§6.4) ----
isOpen(uriString: string): boolean {
return this.panels.has(uriString);
}
/**
* F11 test seam: deliver an inbound webview message to the real routing, as if
* the sealed webview had posted it. Exercises messageseam wiring without a
* live webview DOM (which is manual-smoke only). No-op if no doc/panel.
*/
receiveMessage(uriString: string, m: ToolbarMsg): void {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m);
}
/**
* 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
* always active (reading is always allowed). False if no panel/doc.
*/
editControlsEnabled(uriString: string): boolean {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
return doc ? isAuthorable(doc.uri.scheme) : false;
}
getLastModel(uriString: string): BlockOp[] | undefined {
return this.lastModel.get(uriString);
}
/** F10 test seam: the review HTML the panel would post for a doc (on-state). */
renderHtmlFor(uriString: string): string {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (!doc) return "";
const current = doc.getText();
const baseline = this.diffView.getBaseline(uriString);
return renderReview(
baseline?.text ?? current,
current,
this.attribution.spansFor(doc),
this.proposals.listProposals(doc),
{ pinned: baseline?.reason === "pinned" },
);
}
/** F10: current annotations mode for a panel (default on). */
getMode(uriString: string): "on" | "off" {
return this.mode.get(uriString) ?? "on";
}
/** F10: set the annotations mode and re-render (the programmatic twin of the header toggle). */
setMode(uriString: string, mode: "on" | "off"): void {
this.mode.set(uriString, mode);
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (doc) this.refresh(doc);
}
/** F10 test seam (SLICE-4 E2E): the off-panel status-bar indicator text, if shown. */
statusText(): string | undefined {
return this.statusItem.text || undefined;
}
dispose(): void {
for (const t of this.debounces.values()) clearTimeout(t);
for (const p of this.panels.values()) p.dispose();
for (const d of this.disposables) d.dispose();
}
}