#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>
This commit is contained in:
@@ -18,7 +18,7 @@ import { addProposal, removeProposal } from "./proposalModel";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import type { VersionGuard } from "./versionGuard";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { ProposalView } from "./trackChangesModel";
|
||||
import { wordEditHunks, type ProposalView } from "./trackChangesModel";
|
||||
|
||||
/** Test-facing snapshot of what is currently rendered for a document. */
|
||||
export interface RenderedProposal {
|
||||
@@ -122,7 +122,7 @@ export class ProposalController implements vscode.Disposable {
|
||||
fp: Fingerprint,
|
||||
replacement: string,
|
||||
author: Provenance,
|
||||
opts?: { turnId?: string; instruction?: string },
|
||||
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;
|
||||
@@ -164,12 +164,19 @@ export class ProposalController implements vscode.Disposable {
|
||||
this.renderAll(document);
|
||||
return false;
|
||||
}
|
||||
const range = new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end));
|
||||
// No awaits between resolve and the seam call: document.version is current.
|
||||
const ok = await this.attribution.applyAgentEdit(document, range, proposal.replacement, proposal.author, {
|
||||
expectedVersion: document.version,
|
||||
turnId: proposal.turnId,
|
||||
});
|
||||
// #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.",
|
||||
@@ -181,6 +188,41 @@ export class ProposalController implements vscode.Disposable {
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user