#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:
@@ -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);
|
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
|
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.
|
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 0–1 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.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const legend = document.getElementById("cw-legend")!;
|
|||||||
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
|
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
|
||||||
const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null;
|
const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null;
|
||||||
const askEl = document.getElementById("cw-ask") 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.
|
// F10: the annotations on/off toggle.
|
||||||
annotationsEl?.addEventListener("change", () => {
|
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).
|
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
|
||||||
body.addEventListener("click", (e) => {
|
body.addEventListener("click", (e) => {
|
||||||
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
|
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;
|
if (askEl) askEl.disabled = !authorable;
|
||||||
const on = msg.mode === "on";
|
const on = msg.mode === "on";
|
||||||
if (annotationsEl) annotationsEl.checked = 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.
|
// Off-state is a clean preview: hide the review chrome.
|
||||||
header.hidden = !on;
|
header.hidden = !on;
|
||||||
summary.hidden = !on;
|
summary.hidden = !on;
|
||||||
|
|||||||
@@ -83,6 +83,11 @@
|
|||||||
"command": "cowriting.editDocument",
|
"command": "cowriting.editDocument",
|
||||||
"title": "Ask Claude to Edit Document",
|
"title": "Ask Claude to Edit Document",
|
||||||
"category": "Cowriting"
|
"category": "Cowriting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.acceptAllProposals",
|
||||||
|
"title": "Accept All Claude Proposals",
|
||||||
|
"category": "Cowriting"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
@@ -110,6 +115,10 @@
|
|||||||
{
|
{
|
||||||
"command": "cowriting.editDocument",
|
"command": "cowriting.editDocument",
|
||||||
"when": "editorLangId == markdown"
|
"when": "editorLangId == markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.acceptAllProposals",
|
||||||
|
"when": "editorLangId == markdown"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor/title": [
|
"editor/title": [
|
||||||
|
|||||||
@@ -107,6 +107,20 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
);
|
);
|
||||||
context.subscriptions.push(trackChangesPreviewController);
|
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 ---
|
// --- F6 machine-landing wiring — now for ANY authorable doc ---
|
||||||
// The seam's single machine-landing signal advances the F6 baseline (INV-18);
|
// 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.
|
// 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) ----------------------------------
|
// ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ----------------------------------
|
||||||
|
|
||||||
/** Accept by proposal id (test-facing twin of the thread-menu gesture). */
|
/** 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);
|
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). */
|
/** Reject by proposal id (test-facing twin of the thread-menu gesture). */
|
||||||
rejectById(docPath: string, proposalId: string): boolean {
|
rejectById(docPath: string, proposalId: string): boolean {
|
||||||
@@ -150,7 +181,7 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
return true;
|
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;
|
if (this.guard.isReadOnly(state.docPath)) return false;
|
||||||
const document = this.openDoc(state);
|
const document = this.openDoc(state);
|
||||||
if (!document) return false;
|
if (!document) return false;
|
||||||
@@ -158,6 +189,8 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
|
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
|
||||||
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
|
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
|
||||||
if (resolved === "orphaned") {
|
if (resolved === "orphaned") {
|
||||||
|
// #46: accept-all suppresses per-proposal warnings (one batch report instead).
|
||||||
|
if (!opts?.silent)
|
||||||
void vscode.window.showWarningMessage(
|
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).",
|
"Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).",
|
||||||
);
|
);
|
||||||
@@ -178,6 +211,7 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
{ expectedVersion: document.version, turnId: proposal.turnId },
|
{ expectedVersion: document.version, turnId: proposal.turnId },
|
||||||
);
|
);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
if (!opts?.silent)
|
||||||
void vscode.window.showWarningMessage(
|
void vscode.window.showWarningMessage(
|
||||||
"Cowriting: the editor rejected the accept — the proposal is still pending.",
|
"Cowriting: the editor rejected the accept — the proposal is still pending.",
|
||||||
);
|
);
|
||||||
|
|||||||
+27
-20
@@ -337,14 +337,16 @@ function alignBlocks(cur: BlockWithRange[], next: BlockWithRange[]): BlockAlignO
|
|||||||
* did and this supersedes for document edits). Built by aligning both sides into
|
* did and this supersedes for document edits). Built by aligning both sides into
|
||||||
* the existing block units (`splitBlocksWithRanges`) and keying with the same diff
|
* the existing block units (`splitBlocksWithRanges`) and keying with the same diff
|
||||||
* as `diffBlocks`:
|
* 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
|
* 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).
|
* text. A code/mermaid fence is one such whole-block hunk (atomic, INV-23). So
|
||||||
* - any other changed RUN (block insertions/deletions, or adjacent changes) → one
|
* a copy-edit pass touching N consecutive paragraphs yields N proposals.
|
||||||
* gap-span hunk covering the source between the bounding unchanged blocks → the
|
* - a run of block INSERTIONS / DELETIONS → one gap-span hunk covering the source
|
||||||
* matching rewritten span (separators included), so reconstruction stays exact.
|
* between the bounding aligned blocks (unchanged OR changed — both are 1:1
|
||||||
* A zero-width gap-span (insert at a seamless boundary) is anchored to an
|
* anchors) → the matching rewritten span (separators included), so
|
||||||
* adjacent token (INV-41) so its F4 fingerprint resolves and it accepts.
|
* 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.
|
* - unchanged blocks (same key AND same raw) → no hunk.
|
||||||
* Pure, vscode-free, deterministic; same `EditHunk` shape as `diffToHunks` (which
|
* Pure, vscode-free, deterministic; same `EditHunk` shape as `diffToHunks` (which
|
||||||
* is RETAINED as the intra-block sub-diff engine for word-precise accept
|
* 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) {
|
for (const op of ops) {
|
||||||
if (op.kind === "unchanged" && cur[op.ci!].raw !== next[op.ni!].raw) op.kind = "changed";
|
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[] = [];
|
const hunks: EditHunk[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < ops.length) {
|
while (i < ops.length) {
|
||||||
if (ops[i].kind === "unchanged") {
|
const op = ops[i];
|
||||||
|
if (op.kind === "unchanged") {
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
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;
|
let j = i;
|
||||||
while (j < ops.length && ops[j].kind !== "unchanged") j++;
|
while (j < ops.length && !isAnchor(ops[j])) j++;
|
||||||
const run = ops.slice(i, j);
|
const prev = i > 0 ? ops[i - 1] : null; // an anchor by construction
|
||||||
if (run.length === 1 && run[0].kind === "changed") {
|
const after = j < ops.length ? ops[j] : null; // an anchor (or null at EOF)
|
||||||
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 curStart = prev ? cur[prev.ci!].end : 0;
|
||||||
const curEnd = after ? cur[after.ci!].start : currentText.length;
|
const curEnd = after ? cur[after.ci!].start : currentText.length;
|
||||||
const newStart = prev ? next[prev.ni!].end : 0;
|
const newStart = prev ? next[prev.ni!].end : 0;
|
||||||
const newEnd = after ? next[after.ni!].start : rewrittenText.length;
|
const newEnd = after ? next[after.ni!].start : rewrittenText.length;
|
||||||
const hunk: EditHunk = { start: curStart, end: curEnd, replacement: rewrittenText.slice(newStart, newEnd) };
|
const hunk: EditHunk = { start: curStart, end: curEnd, replacement: rewrittenText.slice(newStart, newEnd) };
|
||||||
hunks.push(hunk.start === hunk.end ? anchorInsertion(hunk, currentText) : hunk);
|
hunks.push(hunk.start === hunk.end ? anchorInsertion(hunk, currentText) : hunk);
|
||||||
}
|
|
||||||
i = j;
|
i = j;
|
||||||
}
|
}
|
||||||
|
hunks.sort((a, b) => a.start - b.start);
|
||||||
return hunks;
|
return hunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ type ToolbarMsg =
|
|||||||
| { type: "reject"; proposalId: string }
|
| { type: "reject"; proposalId: string }
|
||||||
| { type: "pinBaseline" }
|
| { type: "pinBaseline" }
|
||||||
| { type: "askClaude"; scope: "document" }
|
| { 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 {
|
export class TrackChangesPreviewController implements vscode.Disposable {
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
@@ -190,9 +191,29 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
const target: EditTarget =
|
const target: EditTarget =
|
||||||
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
|
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
|
||||||
void this.askClaude(document, target);
|
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
|
* 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
|
* 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>
|
<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-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-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-epoch">Review</span>
|
||||||
<span id="cw-summary"></span>
|
<span id="cw-summary"></span>
|
||||||
<span id="cw-legend"></span>
|
<span id="cw-legend"></span>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user