#46 (SLICE-3, accept): Accept all pending proposals in one gesture

Document-edit flow SLICE-3 — completes reach→review→accept
(specs/coauthoring-document-edit-flow.md §7.2, INV-42). A single "Accept all"
gesture applies every pending proposal on the current document through the
existing F4 acceptById seam (block proposals take the INV-40 word-precise path
automatically), in descending anchor order so an earlier accept never invalidates
a later one, skipping (never force-applying) proposals that can't anchor and
reporting applied-vs-skipped. Batched application of the existing accept path — no
new mechanism; the webview posts intent only (INV-35). No confirmation dialog
(undo restores).

- proposalController.ts: acceptAllProposals(document) → {applied, skipped}
  (descending order, orphan-skip); accept/acceptById gain a silent opt so the
  batch suppresses N per-proposal orphan warnings in favour of one report.
- trackChangesPreview.ts: ToolbarMsg += {type:"acceptAll"}; handleWebviewMessage
  routes it to a public acceptAll(document) that batches + reports.
- extension.ts + package.json: cowriting.acceptAllProposals command (active doc,
  markdown-gated palette entry) for the non-webview path.
- media/preview.ts + shellHtml: "✓✓ Accept all" toolbar button, posting the
  intent, shown only with ≥2 pending proposals (authorable, on-state).
- trackChangesModel.ts: diffToBlockHunks now emits one block-aligned hunk per
  CHANGED block even when changed blocks are ADJACENT (treats changed blocks as
  1:1 anchors alongside unchanged ones; gap-spans only cover add/remove runs
  between anchors) — fixes adjacent changed blocks collapsing into one proposal.
- f12Accept host E2E (apply-all reconstructs; orphan skip + report; single
  proposal; command registered/gated); MANUAL-SMOKE-F12 §3.

214 unit + 73/5 host E2E green. Completes the document-edit-flow cluster
(#42 reach + #47 review + #46 accept).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-13 08:15:55 -07:00
parent 6fd7555183
commit c94b9ccfe7
8 changed files with 286 additions and 36 deletions
+27
View File
@@ -67,3 +67,30 @@ light copy-edit instruction (e.g. "tighten the prose, fix typos").
A document edit yields one in-context proposal per changed block (fences atomic);
accepting a block lands the whole block but Claude-attributes only the words it
changed; inserted blocks accept; selection edits are unaffected; no console errors.
## SLICE-3 — #46 (accept): Accept all (INV-42)
On a markdown doc, **Ask Claude to Edit Document** with a pass that changes
**several** blocks, so the preview shows **≥ 2** pending proposals.
1. **Button appears at ≥2 (PUC-6).** The preview toolbar shows **✓✓ Accept all**
only when there are **2 or more** pending proposals (and the doc is authorable,
annotations on). With 01 pending it is hidden.
2. **One gesture applies all.** Click **Accept all**: every pending proposal lands
(Claude-attributed per INV-40), the ✓/✗ blocks clear, and a status message
reports how many were accepted. No confirmation dialog.
3. **Undo restores.** `Ctrl+Z` walks back the applied edits (consistent with
single accept; a block accept is itself N steps — see SLICE-2).
4. **Orphan-skip + report.** If one proposal's target text was changed by hand
first (so it can't anchor), Accept all applies the rest and the report says
`… , N skipped (target text changed — undo or reject)`; the orphaned proposal
stays pending, its text untouched (never force-applied).
5. **Command path.** With no preview panel open, the command palette **Cowriting:
Accept All Claude Proposals** (markdown-gated) applies all proposals on the
active doc with the same report.
### Pass criteria
Accept all is offered only at ≥2 pending; one click applies every resolvable
proposal and reports the tally; orphans are skipped (not mangled) and remain
pending; the palette command works on the active doc; no console errors.
+9
View File
@@ -30,6 +30,7 @@ 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", () => {
@@ -88,6 +89,11 @@ askEl?.addEventListener("click", () => {
}
});
// #46 (INV-42): Accept all — batch-accept every pending proposal (intent only).
acceptAllEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "acceptAll" });
});
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
body.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
@@ -132,6 +138,9 @@ window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
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;
+9
View File
@@ -83,6 +83,11 @@
"command": "cowriting.editDocument",
"title": "Ask Claude to Edit Document",
"category": "Cowriting"
},
{
"command": "cowriting.acceptAllProposals",
"title": "Accept All Claude Proposals",
"category": "Cowriting"
}
],
"menus": {
@@ -110,6 +115,10 @@
{
"command": "cowriting.editDocument",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.acceptAllProposals",
"when": "editorLangId == markdown"
}
],
"editor/title": [
+14
View File
@@ -107,6 +107,20 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
);
context.subscriptions.push(trackChangesPreviewController);
// #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.
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => {
const doc = vscode.window.activeTextEditor?.document;
if (!doc || doc.languageId !== "markdown") {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals.");
return;
}
await trackChangesPreviewController.acceptAll(doc);
}),
);
// --- 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.
+43 -9
View File
@@ -138,9 +138,40 @@ export class ProposalController implements vscode.Disposable {
// ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ----------------------------------
/** Accept by proposal id (test-facing twin of the thread-menu gesture). */
async acceptById(docPath: string, proposalId: string): Promise<boolean> {
async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
return hit ? this.accept(hit.state, hit.proposal) : false;
return hit ? this.accept(hit.state, hit.proposal, opts) : false;
}
/**
* #46 (INV-42): accept EVERY pending proposal on a document in one gesture a
* batched application of the existing `acceptById` seam, not a new mechanism.
* Block proposals take the INV-40 word-precise path automatically. Applied in
* DESCENDING anchor order so an earlier accept never invalidates a later one's
* offsets; proposals whose anchor can't resolve are SKIPPED (never force-applied)
* and counted. Returns the applied-vs-skipped tally for the caller to report.
*/
async acceptAllProposals(document: vscode.TextDocument): Promise<{ applied: number; skipped: number }> {
if (!this.isTracked(document)) return { applied: 0, skipped: 0 };
const state = this.ensureState(document);
state.artifact = this.store.load(state.docPath) ?? emptyArtifact(state.docPath);
const text = document.getText();
const items = state.artifact.proposals.map((p) => {
const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
const resolved = fp ? resolve(text, fp) : "orphaned";
return { id: p.id, start: resolved === "orphaned" ? null : resolved.start };
});
const resolvable = items
.filter((i): i is { id: string; start: number } => i.start !== null)
.sort((a, b) => b.start - a.start);
let applied = 0;
let skipped = items.length - resolvable.length; // orphans, skipped up front
for (const it of resolvable) {
// silent: one batch report stands in for N per-proposal warnings.
if (await this.acceptById(state.docPath, it.id, { silent: true })) applied++;
else skipped++;
}
return { applied, skipped };
}
/** Reject by proposal id (test-facing twin of the thread-menu gesture). */
rejectById(docPath: string, proposalId: string): boolean {
@@ -150,7 +181,7 @@ export class ProposalController implements vscode.Disposable {
return true;
}
private async accept(state: DocState, proposal: Proposal): Promise<boolean> {
private async accept(state: DocState, proposal: Proposal, opts?: { silent?: boolean }): Promise<boolean> {
if (this.guard.isReadOnly(state.docPath)) return false;
const document = this.openDoc(state);
if (!document) return false;
@@ -158,9 +189,11 @@ export class ProposalController implements vscode.Disposable {
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
if (resolved === "orphaned") {
void vscode.window.showWarningMessage(
"Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).",
);
// #46: accept-all suppresses per-proposal warnings (one batch report instead).
if (!opts?.silent)
void vscode.window.showWarningMessage(
"Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).",
);
this.renderAll(document);
return false;
}
@@ -178,9 +211,10 @@ export class ProposalController implements vscode.Disposable {
{ expectedVersion: document.version, turnId: proposal.turnId },
);
if (!ok) {
void vscode.window.showWarningMessage(
"Cowriting: the editor rejected the accept — the proposal is still pending.",
);
if (!opts?.silent)
void vscode.window.showWarningMessage(
"Cowriting: the editor rejected the accept — the proposal is still pending.",
);
return false;
}
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
+33 -26
View File
@@ -337,14 +337,16 @@ function alignBlocks(cur: BlockWithRange[], next: BlockWithRange[]): BlockAlignO
* did and this supersedes for document edits). Built by aligning both sides into
* the existing block units (`splitBlocksWithRanges`) and keying with the same diff
* as `diffBlocks`:
* - an ISOLATED changed block (1:1, unchanged blocks on both sides) a
* - a CHANGED block (1:1 aligned, even when adjacent to other changed blocks) a
* block-aligned hunk `[block.start, block.end)` the rewritten block's raw
* text. A code/mermaid fence is one such whole-block hunk (atomic, INV-23).
* - any other changed RUN (block insertions/deletions, or adjacent changes) one
* gap-span hunk covering the source between the bounding unchanged blocks the
* matching rewritten span (separators included), so reconstruction stays exact.
* A zero-width gap-span (insert at a seamless boundary) is anchored to an
* adjacent token (INV-41) so its F4 fingerprint resolves and it accepts.
* text. A code/mermaid fence is one such whole-block hunk (atomic, INV-23). So
* a copy-edit pass touching N consecutive paragraphs yields N proposals.
* - a run of block INSERTIONS / DELETIONS one gap-span hunk covering the source
* between the bounding aligned blocks (unchanged OR changed both are 1:1
* anchors) the matching rewritten span (separators included), so
* reconstruction stays exact. A zero-width gap-span (insert at a seamless
* boundary) is anchored to an adjacent token (INV-41) so its F4 fingerprint
* resolves and it accepts.
* - unchanged blocks (same key AND same raw) no hunk.
* Pure, vscode-free, deterministic; same `EditHunk` shape as `diffToHunks` (which
* is RETAINED as the intra-block sub-diff engine for word-precise accept
@@ -359,34 +361,39 @@ export function diffToBlockHunks(currentText: string, rewrittenText: string): Ed
for (const op of ops) {
if (op.kind === "unchanged" && cur[op.ci!].raw !== next[op.ni!].raw) op.kind = "changed";
}
// `changed` and `unchanged` are 1:1 anchors (both sides' offsets are known);
// `added`/`removed` have no counterpart and must be spanned together.
const isAnchor = (op: BlockAlignOp) => op.kind === "unchanged" || op.kind === "changed";
const hunks: EditHunk[] = [];
let i = 0;
while (i < ops.length) {
if (ops[i].kind === "unchanged") {
const op = ops[i];
if (op.kind === "unchanged") {
i++;
continue;
}
let j = i;
while (j < ops.length && ops[j].kind !== "unchanged") j++;
const run = ops.slice(i, j);
if (run.length === 1 && run[0].kind === "changed") {
const c = cur[run[0].ci!];
hunks.push({ start: c.start, end: c.end, replacement: next[run[0].ni!].raw });
} else {
// A run with insertions/deletions: replace the whole inter-anchor gap so the
// separators reconstruct exactly. Bound by the unchanged blocks on each side
// (or the document edges).
const prev = i > 0 ? ops[i - 1] : null;
const after = j < ops.length ? ops[j] : null;
const curStart = prev ? cur[prev.ci!].end : 0;
const curEnd = after ? cur[after.ci!].start : currentText.length;
const newStart = prev ? next[prev.ni!].end : 0;
const newEnd = after ? next[after.ni!].start : rewrittenText.length;
const hunk: EditHunk = { start: curStart, end: curEnd, replacement: rewrittenText.slice(newStart, newEnd) };
hunks.push(hunk.start === hunk.end ? anchorInsertion(hunk, currentText) : hunk);
if (op.kind === "changed") {
const c = cur[op.ci!];
hunks.push({ start: c.start, end: c.end, replacement: next[op.ni!].raw });
i++;
continue;
}
// A maximal run of added/removed blocks → one gap-span hunk over the source
// between the bounding anchors (or the document edges), replaced with the
// matching rewritten span, so the separators reconstruct exactly.
let j = i;
while (j < ops.length && !isAnchor(ops[j])) j++;
const prev = i > 0 ? ops[i - 1] : null; // an anchor by construction
const after = j < ops.length ? ops[j] : null; // an anchor (or null at EOF)
const curStart = prev ? cur[prev.ci!].end : 0;
const curEnd = after ? cur[after.ci!].start : currentText.length;
const newStart = prev ? next[prev.ni!].end : 0;
const newEnd = after ? next[after.ni!].start : rewrittenText.length;
const hunk: EditHunk = { start: curStart, end: curEnd, replacement: rewrittenText.slice(newStart, newEnd) };
hunks.push(hunk.start === hunk.end ? anchorInsertion(hunk, currentText) : hunk);
i = j;
}
hunks.sort((a, b) => a.start - b.start);
return hunks;
}
+23 -1
View File
@@ -37,7 +37,8 @@ type ToolbarMsg =
| { type: "reject"; proposalId: string }
| { type: "pinBaseline" }
| { type: "askClaude"; scope: "document" }
| { type: "askClaude"; scope: "selection"; start: number; end: number };
| { type: "askClaude"; scope: "selection"; start: number; end: number }
| { type: "acceptAll" };
export class TrackChangesPreviewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
@@ -190,9 +191,29 @@ export class TrackChangesPreviewController implements vscode.Disposable {
const target: EditTarget =
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
void this.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);
}
}
/**
* #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}.`,
);
}
/**
* 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
@@ -392,6 +413,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
<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>
+128
View File
@@ -0,0 +1,128 @@
import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers");
return api;
}
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 });
fs.writeFileSync(abs, body, "utf8");
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settle();
return { doc, key: uri.toString() };
}
// #46 SLICE-3 (accept): one "Accept all" gesture applies every pending proposal
// on the current document through the existing F4 accept seam (INV-42) — batched,
// re-anchor-safe (descending), orphan-skip + report. Host E2E, no LLM.
suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
// PUC-6: N pending → all applied, text replaced, proposals cleared.
test("acceptAll applies every pending proposal and reconstructs the document", async () => {
const original = "# All\n\nFirst para alpha.\n\nSecond para beta.\n\nThird para gamma.\n";
const rewrite = "# All\n\nFirst para ALPHA.\n\nSecond para BETA.\n\nThird para GAMMA.\n";
const { doc, key } = await freshDoc("docs/f12-all.md", original);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
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");
await settle();
assert.strictEqual(ids.length, 3, "three changed blocks → three pending proposals");
// Simulate the toolbar "Accept all" button posting its intent.
ctl.receiveMessage(key, { type: "acceptAll" });
await settle();
await settle();
assert.strictEqual(doc.getText(), rewrite, "accept-all reconstructs the intended document");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "all proposals cleared");
});
// PUC-6 / INV-42: an orphaned proposal is skipped (not force-applied) and the
// resolvable ones still apply — reported via the {applied, skipped} tally.
test("acceptAllProposals skips an orphaned proposal and applies the rest (report)", async () => {
const original = "# Mix\n\nKeep alpha here.\n\nKeep gamma here.\n";
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;
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");
await settle();
assert.strictEqual(ids.length, 2, "two pending proposals");
// Orphan the FIRST block's proposal by mangling its target text in the buffer.
const para = "Keep alpha here.";
const start = doc.getText().indexOf(para);
const edit = new vscode.WorkspaceEdit();
edit.replace(
doc.uri,
new vscode.Range(doc.positionAt(start), doc.positionAt(start + para.length)),
"Totally different first paragraph now.",
);
assert.ok(await vscode.workspace.applyEdit(edit), "mangling edit applied");
await settle();
const { applied, skipped } = await api.proposalController.acceptAllProposals(doc);
assert.strictEqual(applied, 1, "the still-resolvable proposal applied");
assert.strictEqual(skipped, 1, "the orphaned proposal was skipped, not mangled");
assert.ok(doc.getText().includes("Keep GAMMA here."), "the resolvable block landed");
assert.ok(
doc.getText().includes("Totally different first paragraph now."),
"the orphaned block kept the operator's text (never force-applied)",
);
assert.strictEqual(api.proposalController.listProposals(doc).length, 1, "the orphaned proposal remains pending");
});
// A single pending proposal still applies through the batch path (the button is
// hidden < 2 pending in the webview, but the command/seam handle any count).
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;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
ctl.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");
await settle();
assert.strictEqual(ids.length, 1, "one pending proposal");
const { applied, skipped } = await api.proposalController.acceptAllProposals(doc);
assert.strictEqual(applied, 1);
assert.strictEqual(skipped, 0);
assert.strictEqual(doc.getText(), "# One\n\nThe ONLY paragraph here.\n");
});
// The command is registered + palette-guarded on markdown.
test("cowriting.acceptAllProposals is registered and palette-guarded on markdown", async () => {
const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.acceptAllProposals"), "command registered");
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.acceptAllProposals",
);
assert.ok(entry, "has a commandPalette entry");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
});
});