From ddbbb7aec351f2a5813a1d5ea9b30344cac4f839 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Fri, 26 Jun 2026 06:31:28 -0700 Subject: [PATCH] feat(proposals): optimisticApply + finalize/revert in place + rejectAll (#64) Co-Authored-By: Claude Sonnet 4.6 --- src/proposalController.ts | 160 ++++++++++++++++++++++++++- src/trackChangesModel.ts | 2 + test/e2e/suite/f12InlineDiff.test.ts | 70 ++++++++++++ 3 files changed, 229 insertions(+), 3 deletions(-) diff --git a/src/proposalController.ts b/src/proposalController.ts index 5e712bf..86acf10 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -13,8 +13,8 @@ import * as vscode from "vscode"; import { SidecarRouter, docIdentity } from "./sidecarRouter"; import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model"; -import { resolve, shift, type OffsetRange } from "./anchorer"; -import { addProposal, removeProposal } from "./proposalModel"; +import { resolve, shift, buildFingerprint, type OffsetRange } from "./anchorer"; +import { addProposal, removeProposal, setProposalApplied } from "./proposalModel"; import type { AttributionController } from "./attributionController"; import type { VersionGuard } from "./versionGuard"; import { isAuthorable } from "./workspacePath"; @@ -39,6 +39,8 @@ interface DocState { live: Map; /** proposal ids whose anchor did not resolve at last render (stale/orphaned). */ unresolved: Set; + /** ids already optimistically applied to the buffer (so the trigger doesn't re-apply). */ + applied: Set; } export class ProposalController implements vscode.Disposable { @@ -91,6 +93,7 @@ export class ProposalController implements vscode.Disposable { anchorEnd: resolved === "orphaned" ? null : resolved.end, replaced: fp?.text ?? "", replacement: p.replacement, + original: p.original, }; }); } @@ -104,6 +107,7 @@ export class ProposalController implements vscode.Disposable { artifact: this.store.load(docPath) ?? emptyArtifact(docPath), live: new Map(), unresolved: new Set(), + applied: new Set(), }; this.docs.set(docPath, state); } @@ -137,8 +141,11 @@ 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). */ + /** Accept by id — F12: finalize the already-applied text in place (INV-51). */ async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise { + if (this.isApplied(docPath, proposalId)) return this.finalizeInPlace(docPath, proposalId); + // Fallback: a proposal that was never optimistically applied (e.g. orphaned at + // apply time) keeps the legacy seam-apply accept. const hit = this.byId(docPath, proposalId); return hit ? this.accept(hit.state, hit.proposal, opts) : false; } @@ -181,6 +188,12 @@ export class ProposalController implements vscode.Disposable { return true; } + /** Reject by id — F12: revert the applied text in place (INV-51). */ + async rejectByIdInPlace(docPath: string, proposalId: string): Promise { + if (this.isApplied(docPath, proposalId)) return this.revertInPlace(docPath, proposalId); + return this.rejectById(docPath, proposalId); + } + private async accept(state: DocState, proposal: Proposal, opts?: { silent?: boolean }): Promise { if (this.guard.isReadOnly(state.docPath)) return false; const document = this.openDoc(state); @@ -257,6 +270,147 @@ export class ProposalController implements vscode.Disposable { return true; } + /** True once this proposal's text is in the buffer (optimistic apply ran). */ + isApplied(docPath: string, proposalId: string): boolean { + return this.docs.get(docPath)?.applied.has(proposalId) ?? false; + } + + /** + * F12/#64 (INV-48): optimistically apply a pending proposal INTO the buffer so + * the editor shows the would-be-accepted result (editable). Reuses the F4 + * word-precise seam (block → per-word hunks, INV-40; single → whole range) but + * with `landBaseline:false` (the change stays pending). Then re-anchors the + * proposal to the applied text and stores the original (`setProposalApplied`), so + * `resolve()` finds it in the mutated buffer and revert/decorate key off it. + * Idempotent: a no-op if already applied. + */ + async optimisticApply(document: vscode.TextDocument, proposalId: string): Promise { + if (!this.isTracked(document) || this.guard.isReadOnly(this.keyOf(document))) return false; + const docPath = this.keyOf(document); + const state = this.ensureState(document); + if (state.applied.has(proposalId)) return true; + state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); + const proposal = state.artifact.proposals.find((p) => p.id === proposalId); + const fp = proposal ? state.artifact.anchors[proposal.anchorId]?.fingerprint : undefined; + if (!proposal || !fp) return false; + const resolved = resolve(document.getText(), fp); + if (resolved === "orphaned") return false; + const original = document.getText( + new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)), + ); + const ok = + proposal.granularity === "block" + ? await this.applyBlockOptimistic(document, resolved, proposal) + : await this.attribution.applyAgentEdit( + document, + new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)), + proposal.replacement, + proposal.author, + { expectedVersion: document.version, turnId: proposal.turnId, landBaseline: false }, + ); + if (!ok) return false; + state.applied.add(proposalId); + // Re-anchor to the applied text now in the buffer (its start is unchanged; its + // end shifts by the net length delta of the replacement). + const appliedStart = resolved.start; + const appliedEnd = appliedStart + proposal.replacement.length; + const appliedFp = buildFingerprint(document.getText(), { start: appliedStart, end: appliedEnd }); + this.store.update(docPath, (a) => setProposalApplied(a, proposalId, appliedFp, original)); + this.renderAll(document); + return true; + } + + /** Block optimistic apply: the INV-40 per-word hunks, but landBaseline:false. */ + private async applyBlockOptimistic( + document: vscode.TextDocument, + resolved: OffsetRange, + proposal: Proposal, + ): Promise { + const blockText = document.getText( + new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)), + ); + const subHunks = wordEditHunks(blockText, proposal.replacement); + if (subHunks.length === 0) return true; + for (const h of [...subHunks].sort((a, b) => b.start - a.start)) { + const range = new vscode.Range( + document.positionAt(resolved.start + h.start), + document.positionAt(resolved.start + h.end), + ); + const ok = await this.attribution.applyAgentEdit(document, range, h.replacement, proposal.author, { + expectedVersion: document.version, turnId: proposal.turnId, landBaseline: false, + }); + if (!ok) return false; + } + return true; + } + + /** + * F12/#64 (INV-51): ACCEPT an optimistically-applied proposal — the text is + * already in the buffer, so this only advances the F6 baseline (machine-landing, + * via `attribution.signalLanded`) and clears the proposal. No re-application. + */ + async finalizeInPlace(docPath: string, proposalId: string): Promise { + const hit = this.byId(docPath, proposalId); + if (!hit) return false; + const document = this.openDoc(hit.state); + if (!document) return false; + this.attribution.signalLanded(document); + this.store.update(docPath, (a) => removeProposal(a, proposalId)); + hit.state.applied.delete(proposalId); + this.renderAll(document); + return true; + } + + /** + * F12/#64 (INV-51): REJECT an optimistically-applied proposal — replace its live + * applied span with the stored `original`, then clear it. Reverts the whole block + * regardless of any in-place edits the human made to the inserted text. + */ + async revertInPlace(docPath: string, proposalId: string): Promise { + const hit = this.byId(docPath, proposalId); + if (!hit) return false; + const document = this.openDoc(hit.state); + if (!document) return false; + const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint; + const resolved = fp ? resolve(document.getText(), fp) : "orphaned"; + if (resolved !== "orphaned" && hit.proposal.original !== undefined) { + const we = new vscode.WorkspaceEdit(); + we.replace( + document.uri, + new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)), + hit.proposal.original, + ); + if (!(await vscode.workspace.applyEdit(we))) return false; + } + this.store.update(docPath, (a) => removeProposal(a, proposalId)); + hit.state.applied.delete(proposalId); + this.renderAll(document); + return true; + } + + /** + * F12/#64 (INV-53): reject EVERY pending proposal on a document — revert each in + * DESCENDING anchor order (so an earlier revert never shifts a later one's + * offsets), symmetric with #46's accept-all. Returns the reverted count. + */ + async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number }> { + if (!this.isTracked(document)) return { reverted: 0 }; + const docPath = this.keyOf(document); + const state = this.ensureState(document); + state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); + const text = document.getText(); + const ordered = state.artifact.proposals + .map((p) => { + const fp = state.artifact.anchors[p.anchorId]?.fingerprint; + const r = fp ? resolve(text, fp) : "orphaned"; + return { id: p.id, start: r === "orphaned" ? -1 : r.start }; + }) + .sort((a, b) => b.start - a.start); + let reverted = 0; + for (const it of ordered) if (await this.revertInPlace(docPath, it.id)) reverted++; + return { reverted }; + } + private reject(state: DocState, proposal: Proposal): void { if (this.guard.isReadOnly(state.docPath)) return; this.store.update(state.docPath, (a) => removeProposal(a, proposal.id)); diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 4235e60..b792c3b 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -701,6 +701,8 @@ export interface ProposalView { replaced: string; /** the proposed replacement text. */ replacement: string; + /** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */ + original?: string; } function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string { diff --git a/test/e2e/suite/f12InlineDiff.test.ts b/test/e2e/suite/f12InlineDiff.test.ts index 3a9c749..5ce3512 100644 --- a/test/e2e/suite/f12InlineDiff.test.ts +++ b/test/e2e/suite/f12InlineDiff.test.ts @@ -46,3 +46,73 @@ suite("F12 inline diff — seam landBaseline (#64, INV-48)", () => { assert.strictEqual(api.diffViewController.getBaseline(key)?.text ?? doc.getText(), before, "baseline unchanged"); }); }); + +suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => { + // Optimistic apply lands the text + re-anchors; the buffer becomes the accepted result. + test("optimisticApply puts the proposed text in the buffer and re-anchors", async () => { + const { doc } = await freshDoc("docs/f12-opt.md", "# T\n\nReplace alpha please.\n"); + const api = await getApi(); + const p = api.proposalController; + const fp = { text: "Replace alpha please.", before: "", after: "", lineHint: 2 }; + const id = await p.propose(doc, fp, "Replace ALPHA please.", + { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }); + await api.proposalController.optimisticApply(doc, id!); + await settle(); + assert.ok(doc.getText().includes("Replace ALPHA please."), "applied to buffer"); + // re-anchored: the proposal still resolves against the mutated buffer + assert.strictEqual(p.listProposals(doc)[0].anchorStart !== null, true, "re-anchored, resolves"); + assert.strictEqual(p.listProposals(doc)[0].original, "Replace alpha please.", "original stored"); + }); + + test("finalizeInPlace clears the proposal and keeps the applied text (no double-apply)", async () => { + const { doc } = await freshDoc("docs/f12-fin.md", "# T\n\nKeep alpha now.\n"); + const api = await getApi(); + const p = api.proposalController; + const docPath = p.keyFor(doc); + const fp = { text: "Keep alpha now.", before: "", after: "", lineHint: 2 }; + const id = await p.propose(doc, fp, "Keep ALPHA now.", + { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }); + await p.optimisticApply(doc, id!); + await settle(); + const ok = await p.finalizeInPlace(docPath, id!); + await settle(); + assert.ok(ok, "finalized"); + assert.strictEqual(doc.getText(), "# T\n\nKeep ALPHA now.\n", "applied text retained, no double-apply"); + assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared"); + }); + + test("revertInPlace restores the original and clears the proposal", async () => { + const { doc } = await freshDoc("docs/f12-rev.md", "# T\n\nUndo alpha here.\n"); + const api = await getApi(); + const p = api.proposalController; + const docPath = p.keyFor(doc); + const fp = { text: "Undo alpha here.", before: "", after: "", lineHint: 2 }; + const id = await p.propose(doc, fp, "Undo ALPHA here.", + { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }); + await p.optimisticApply(doc, id!); + await settle(); + const ok = await p.revertInPlace(docPath, id!); + await settle(); + assert.ok(ok, "reverted"); + assert.strictEqual(doc.getText(), "# T\n\nUndo alpha here.\n", "original restored"); + assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared"); + }); + + test("rejectAll reverts every pending proposal", async () => { + const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n"); + const api = await getApi(); + const ctl = api.trackChangesPreviewController; + const p = api.proposalController; + ctl.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" })); + const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); + await settle(); + for (const id of ids) await p.optimisticApply(doc, id); + await settle(); + assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"), "both applied"); + const { reverted } = await p.rejectAll(doc); + await settle(); + assert.strictEqual(reverted, ids.length, "all reverted"); + assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document restored"); + assert.strictEqual(p.listProposals(doc).length, 0, "all cleared"); + }); +});