#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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user