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