#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
+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>