Files
vscode-cowriting-plugin/src/proposalController.ts
T
Ben Stull 2f6008ba2b #47 (SLICE-2, review): per-block document proposals + word-precise accept
Document-edit flow SLICE-2 (specs/coauthoring-document-edit-flow.md §7.2,
INV-39/40/41 — P1 "too much to review"). A whole-document rewrite now proposes
ONE F4 proposal per CHANGED BLOCK (the unit a human reviews), but accepting a
block reconciles attribution at WORD granularity (the unit F3 records). Block =
decision unit; word = attribution unit. Supersedes INV-37's per-word cut for
document edits; selection edits unchanged.

- trackChangesModel.ts: new pure diffToBlockHunks(current, rewritten) — block-key
  alignment (reusing diffArrays/diffBlocks keying): an isolated changed block →
  one block-aligned hunk → the rewritten block raw (a code/mermaid fence is one
  atomic whole-fence hunk, INV-23); insert/delete runs → one gap-span hunk over
  the inter-anchor region (separators included) so reconstruction stays exact; a
  zero-width gap-span is anchored (INV-41). Also split diffToHunks into the raw,
  un-anchored wordEditHunks + the anchoring wrapper (the anchoring could grow an
  insertion to overlap an adjacent hunk, corrupting a batch apply — a latent bug
  that only surfaced once hunks are applied as a batch).
- trackChangesPreview.ts: runEditAndPropose document branch uses diffToBlockHunks
  and tags each proposal granularity:"block".
- model.ts / proposalModel.ts: additive optional Proposal.granularity
  ("block"|"single"; absent ⇒ single, back-compat — no migration).
- proposalController.ts: accept of a block proposal runs an intra-block word
  sub-diff (wordEditHunks, disjoint) and applies one applyAgentEdit per changed
  run, descending offset — only the words Claude changed land Claude-attributed;
  unchanged spans keep prior authorship (INV-40).
- Tests: diffToBlockHunks unit (reconstruction + fence atomic + add/remove);
  f12Review host E2E (M blocks→M proposals, unchanged→none, fence atomic, INV-40
  attribution, INV-41 insertion accept); updated the f11 document-path E2E to
  per-block (INV-39 supersedes INV-37); MANUAL-SMOKE-F12 §2.

Seam note: pendingEdits.matchEvent resolves one registration per change event, so
INV-40's per-run attribution is sequential applyAgentEdit calls (N undo steps),
not one multi-replace WorkspaceEdit — see transcript Deferred decisions.

214 unit + 69/5 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:01:16 -07:00

334 lines
14 KiB
TypeScript

/**
* ProposalController — the editor-facing layer for F4 (spec
* coauthoring-propose-accept §6.2). Owns the proposal lifecycle: the propose
* ingress (INV-10: NEVER mutates the document), persistence at propose time,
* resolve-or-flag on load/external change (INV-11 — a proposal's anchor is
* immutable for its life: no save-time re-fingerprint, unlike threads),
* anchor bookkeeping into state.live/state.unresolved (no in-editor UI —
* F10/INV-32 makes the rendered preview the single review surface),
* and the human-only accept/reject gestures (INV-12). Accept drives the seam
* (AttributionController.applyAgentEdit, INV-9) so accepted text lands
* Claude-attributed with zero new attribution code.
*/
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 type { AttributionController } from "./attributionController";
import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath";
import { wordEditHunks, type ProposalView } from "./trackChangesModel";
/** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedProposal {
id: string;
/** true → resolves exactly, decidable; false → stale/orphaned (INV-11). */
pending: boolean;
/** always false — proposals are decide-only, no reply input (INV-12). */
canReply: boolean;
turnId?: string;
range: { start: number; end: number };
}
interface DocState {
docPath: string;
uri: vscode.Uri;
artifact: Artifact;
/** proposal id -> live offset range (within-session optimization, INV-3). */
live: Map<string, OffsetRange>;
/** proposal ids whose anchor did not resolve at last render (stale/orphaned). */
unresolved: Set<string>;
}
export class ProposalController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocState>(); // keyed by docPath
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 89);
private readonly onDidChangeProposalsEmitter = new vscode.EventEmitter<{ uri: string }>();
/** Fires on propose / accept / reject / external sidecar change (F10). */
readonly onDidChangeProposals = this.onDidChangeProposalsEmitter.event;
constructor(
private readonly store: SidecarRouter,
private readonly attribution: AttributionController,
private readonly rootDir: string | undefined,
private readonly guard: VersionGuard,
) {
this.disposables.push(this.statusItem);
this.disposables.push(this.onDidChangeProposalsEmitter);
this.disposables.push(
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
);
}
private fireChanged(document: vscode.TextDocument): void {
this.onDidChangeProposalsEmitter.fire({ uri: document.uri.toString() });
}
private isTracked(document: vscode.TextDocument): boolean {
return isAuthorable(document.uri.scheme);
}
/** The single document key (F8): repo-relative path in-workspace, URI string otherwise. */
private keyOf(document: vscode.TextDocument): string {
return this.store.keyOf(docIdentity(document));
}
/** The doc key F4 uses (F8 routing) — exposed for F10's preview. */
keyFor(document: vscode.TextDocument): string {
return this.keyOf(document);
}
/** Resolved proposal views for the F10 preview (anchorStart=null when unresolved). */
listProposals(document: vscode.TextDocument): ProposalView[] {
const docPath = this.keyOf(document);
const artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
const text = document.getText();
return artifact.proposals.map((p) => {
const fp = artifact.anchors[p.anchorId]?.fingerprint;
const resolved = fp ? resolve(text, fp) : "orphaned";
return {
id: p.id,
anchorStart: resolved === "orphaned" ? null : resolved.start,
anchorEnd: resolved === "orphaned" ? null : resolved.end,
replaced: fp?.text ?? "",
replacement: p.replacement,
};
});
}
private ensureState(document: vscode.TextDocument): DocState {
const docPath = this.keyOf(document);
let state = this.docs.get(docPath);
if (!state) {
state = {
docPath,
uri: document.uri,
artifact: this.store.load(docPath) ?? emptyArtifact(docPath),
live: new Map(),
unresolved: new Set(),
};
this.docs.set(docPath, state);
}
return state;
}
// ---- PUC-1: the propose ingress (INV-10) -----------------------------------------
/**
* Record + render a pending proposal. The fingerprint is built by the CALLER
* at gesture time (editSelection captures it BEFORE the turn, so mid-turn
* edits can't skew the anchor). Never touches the document.
*/
async propose(
document: vscode.TextDocument,
fp: Fingerprint,
replacement: string,
author: Provenance,
opts?: { turnId?: string; instruction?: string; granularity?: "block" | "single" },
): Promise<string | undefined> {
if (!this.isTracked(document)) return undefined;
if (this.guard.isReadOnly(this.keyOf(document))) return undefined;
const docPath = this.keyOf(document);
let proposalId: string | undefined;
this.store.update(docPath, (a) => {
proposalId = addProposal(a, fp, replacement, author, opts).proposalId;
});
this.renderAll(document);
return proposalId;
}
// ---- 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> {
const hit = this.byId(docPath, proposalId);
return hit ? this.accept(hit.state, hit.proposal) : false;
}
/** Reject by proposal id (test-facing twin of the thread-menu gesture). */
rejectById(docPath: string, proposalId: string): boolean {
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
this.reject(hit.state, hit.proposal);
return true;
}
private async accept(state: DocState, proposal: Proposal): Promise<boolean> {
if (this.guard.isReadOnly(state.docPath)) return false;
const document = this.openDoc(state);
if (!document) return false;
// INV-11: the fingerprint-guard — exact re-resolve at decision time.
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).",
);
this.renderAll(document);
return false;
}
// #47 (INV-40): a BLOCK proposal applies the whole block but attributes only
// the words Claude actually changed; a single proposal applies its whole range.
const ok =
proposal.granularity === "block"
? await this.acceptBlock(document, resolved, proposal)
: await this.attribution.applyAgentEdit(
document,
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
proposal.replacement,
proposal.author,
// No awaits between resolve and the seam call: document.version is current.
{ expectedVersion: document.version, turnId: proposal.turnId },
);
if (!ok) {
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));
this.renderAll(document);
return true;
}
/**
* #47 (INV-40): accept a BLOCK proposal — apply Claude's whole block, but
* attribute ONLY the runs Claude actually changed. The block is the DECISION
* unit; the word is the ATTRIBUTION unit. An intra-block word sub-diff
* (`wordEditHunks`, the raw engine INV-37 used, repurposed — un-anchored so the
* runs stay disjoint for a batch apply) yields the changed runs; each lands
* through the F4 seam (Claude-attributed), applied last-position-first so an
* earlier run's offsets stay valid under the later ones. Unchanged spans within
* the block are never touched, so their prior authorship stands. (Each run is
* one seam edit / one undo step — see the spec's deferred note on undo grouping.)
*/
private async acceptBlock(
document: vscode.TextDocument,
resolved: OffsetRange,
proposal: Proposal,
): Promise<boolean> {
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; // block already equals the proposal — nothing to attribute
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,
});
if (!ok) return false;
}
return true;
}
private reject(state: DocState, proposal: Proposal): void {
if (this.guard.isReadOnly(state.docPath)) return;
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
const document = this.openDoc(state);
if (document) this.renderAll(document);
}
// ---- PUC-4: load / external change / resolve-or-flag --------------------------------
/** Load + (re)render every pending proposal at its resolved anchor (or flagged). */
renderAll(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return;
const docPath = this.keyOf(document);
const state = this.ensureState(document);
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
state.live.clear();
state.unresolved.clear();
const text = document.getText();
for (const proposal of state.artifact.proposals) {
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(text, fp) : "orphaned";
if (resolved === "orphaned") {
const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0;
const off = document.offsetAt(new vscode.Position(line, 0));
this.recordProposal(state, proposal, { start: off, end: off }, false);
} else {
this.recordProposal(state, proposal, resolved, true);
}
}
this.renderStatus(state);
this.fireChanged(document);
}
/** Shared-watcher entry point (extension.ts): a sidecar changed externally. */
handleExternalSidecarChange(uri: vscode.Uri): void {
for (const state of this.docs.values()) {
if (this.store.sidecarPath(state.docPath) === uri.fsPath) {
const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath);
if (doc) this.renderAll(doc);
}
}
}
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
const state = this.docs.get(this.keyOf(e.document));
if (!state || state.live.size === 0) return;
for (const change of e.contentChanges) {
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
for (const [id, range] of state.live) state.live.set(id, shift(range, edit));
}
}
// ---- rendering -----------------------------------------------------------------------
/**
* Record a proposal's resolved anchor in the live/unresolved bookkeeping.
* No editor UI (F10/INV-32: the rendered preview is the single review
* surface) — this keeps state.live/state.unresolved populated so the preview
* (SLICE-3) and the getRendered test seam can read it.
*/
private recordProposal(state: DocState, proposal: Proposal, offsets: OffsetRange, pending: boolean): void {
state.live.set(proposal.id, offsets);
if (!pending) state.unresolved.add(proposal.id);
}
private renderStatus(state: DocState): void {
const n = state.unresolved.size;
if (n === 0) {
this.statusItem.hide();
return;
}
this.statusItem.text = `$(warning) ${n} stale proposal${n === 1 ? "" : "s"}`;
this.statusItem.tooltip =
"Cowriting: proposals whose target text changed or is missing — undo to restore, or reject";
this.statusItem.show();
}
// ---- lookups ----------------------------------------------------------------------------
private openDoc(state: DocState): vscode.TextDocument | undefined {
return vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath);
}
private byId(docPath: string, proposalId: string): { state: DocState; proposal: Proposal } | undefined {
const state = this.docs.get(docPath);
const proposal = state?.artifact.proposals.find((p) => p.id === proposalId);
return state && proposal ? { state, proposal } : undefined;
}
// ---- test-facing surface ------------------------------------------------------------------
getRendered(docPath: string): RenderedProposal[] {
const state = this.docs.get(docPath);
if (!state) return [];
const out: RenderedProposal[] = [];
for (const [id, off] of state.live) {
const p = state.artifact.proposals.find((x) => x.id === id)!;
out.push({ id, pending: !state.unresolved.has(id), canReply: false, turnId: p.turnId, range: { start: off.start, end: off.end } });
}
return out;
}
getStaleCount(docPath: string): number {
return this.docs.get(docPath)?.unresolved.size ?? 0;
}
dispose(): void {
for (const d of this.disposables) d.dispose();
}
}