#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:
@@ -90,6 +90,13 @@ export interface Proposal {
|
||||
turnId?: string;
|
||||
/** what the human asked for (review context). */
|
||||
instruction?: string;
|
||||
/**
|
||||
* F12/#47 (INV-39/40): the review-decision unit this proposal represents.
|
||||
* `"block"` ⇒ the anchor spans a whole document block and accept reconciles
|
||||
* attribution per WORD inside it (INV-40); `"single"` (or absent, for
|
||||
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
|
||||
*/
|
||||
granularity?: "block" | "single";
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -12,7 +12,7 @@ export function addProposal(
|
||||
fp: Fingerprint,
|
||||
replacement: string,
|
||||
author: Provenance,
|
||||
opts?: { turnId?: string; instruction?: string },
|
||||
opts?: { turnId?: string; instruction?: string; granularity?: "block" | "single" },
|
||||
): { proposalId: string; anchorId: string } {
|
||||
const anchorId = newId("a");
|
||||
const proposalId = newId("pr");
|
||||
@@ -25,6 +25,7 @@ export function addProposal(
|
||||
createdAt: new Date().toISOString(),
|
||||
...(opts?.turnId !== undefined ? { turnId: opts.turnId } : {}),
|
||||
...(opts?.instruction !== undefined ? { instruction: opts.instruction } : {}),
|
||||
...(opts?.granularity !== undefined ? { granularity: opts.granularity } : {}),
|
||||
});
|
||||
return { proposalId, anchorId };
|
||||
}
|
||||
|
||||
+119
-1
@@ -223,11 +223,27 @@ export interface EditHunk {
|
||||
* never part of another hunk.
|
||||
*/
|
||||
export function diffToHunks(currentText: string, rewrittenText: string): EditHunk[] {
|
||||
return wordEditHunks(currentText, rewrittenText).map((h) =>
|
||||
h.start === h.end ? anchorInsertion(h, currentText) : h,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw word-level diff underlying `diffToHunks`: disjoint, ordered EditHunks
|
||||
* that exactly partition the change (a pure insertion stays ZERO-WIDTH at its
|
||||
* offset — not anchored). `diffToHunks` adds anchoring on top so each hunk's F4
|
||||
* fingerprint resolves; callers that apply hunks directly through the seam
|
||||
* (INV-40's intra-block accept) want THESE raw, non-overlapping hunks instead —
|
||||
* anchoring can grow an insertion to absorb a token an adjacent hunk also edits,
|
||||
* which corrupts a batch apply. Pure, vscode-free, deterministic. Applying these
|
||||
* right→left reconstructs `rewrittenText` exactly.
|
||||
*/
|
||||
export function wordEditHunks(currentText: string, rewrittenText: string): EditHunk[] {
|
||||
const hunks: EditHunk[] = [];
|
||||
let offset = 0;
|
||||
let open: EditHunk | null = null;
|
||||
const flush = () => {
|
||||
if (open) hunks.push(open.start === open.end ? anchorInsertion(open, currentText) : open);
|
||||
if (open) hunks.push(open);
|
||||
open = null;
|
||||
};
|
||||
for (const part of diffWordsWithSpace(currentText, rewrittenText)) {
|
||||
@@ -272,6 +288,108 @@ function anchorInsertion(hunk: EditHunk, text: string): EditHunk {
|
||||
return { start: s, end: p, replacement: text.slice(s, p) + hunk.replacement };
|
||||
}
|
||||
|
||||
type BlockAlignKind = "unchanged" | "changed" | "removed" | "added";
|
||||
interface BlockAlignOp {
|
||||
kind: BlockAlignKind;
|
||||
ci?: number; // index into the current blocks
|
||||
ni?: number; // index into the rewritten blocks
|
||||
}
|
||||
|
||||
/**
|
||||
* Align two block sequences by normalized key (jsdiff `diffArrays`), pairing an
|
||||
* adjacent removed-then-added run element-wise into `changed` ops (the surplus
|
||||
* staying `removed` / `added`) — the same pairing `diffBlocks` uses, but yielding
|
||||
* index ops so the caller can read each side's source range. Pure.
|
||||
*/
|
||||
function alignBlocks(cur: BlockWithRange[], next: BlockWithRange[]): BlockAlignOp[] {
|
||||
const changes = diffArrays(
|
||||
cur.map((b) => b.key),
|
||||
next.map((b) => b.key),
|
||||
);
|
||||
const ops: BlockAlignOp[] = [];
|
||||
let bi = 0; // current index
|
||||
let ci = 0; // rewritten index
|
||||
for (let n = 0; n < changes.length; n++) {
|
||||
const ch = changes[n];
|
||||
const count = ch.count ?? ch.value.length;
|
||||
if (!ch.added && !ch.removed) {
|
||||
for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", ci: bi++, ni: ci++ });
|
||||
continue;
|
||||
}
|
||||
if (ch.removed) {
|
||||
const nx = changes[n + 1];
|
||||
const addCount = nx?.added ? (nx.count ?? nx.value.length) : 0;
|
||||
const paired = Math.min(count, addCount);
|
||||
for (let k = 0; k < paired; k++) ops.push({ kind: "changed", ci: bi++, ni: ci++ });
|
||||
for (let k = paired; k < count; k++) ops.push({ kind: "removed", ci: bi++ });
|
||||
for (let k = paired; k < addCount; k++) ops.push({ kind: "added", ni: ci++ });
|
||||
if (nx?.added) n++;
|
||||
continue;
|
||||
}
|
||||
for (let k = 0; k < count; k++) ops.push({ kind: "added", ni: ci++ });
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
/**
|
||||
* #47 (INV-39, §6.4): diff a whole-document rewrite into ONE EditHunk per CHANGED
|
||||
* BLOCK — the unit a human reviews — rather than per word (which INV-37/`diffToHunks`
|
||||
* 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
|
||||
* 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.
|
||||
* - 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
|
||||
* attribution — INV-40). Applying all hunks right→left reconstructs `rewrittenText`
|
||||
* exactly.
|
||||
*/
|
||||
export function diffToBlockHunks(currentText: string, rewrittenText: string): EditHunk[] {
|
||||
const cur = splitBlocksWithRanges(currentText);
|
||||
const next = splitBlocksWithRanges(rewrittenText);
|
||||
const ops = alignBlocks(cur, next);
|
||||
// Same key but different raw (whitespace/case) is still a real content change.
|
||||
for (const op of ops) {
|
||||
if (op.kind === "unchanged" && cur[op.ci!].raw !== next[op.ni!].raw) op.kind = "changed";
|
||||
}
|
||||
const hunks: EditHunk[] = [];
|
||||
let i = 0;
|
||||
while (i < ops.length) {
|
||||
if (ops[i].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);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
||||
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
|
||||
// other fences fall through to markdown-it's default (escaped <pre><code>).
|
||||
|
||||
@@ -13,7 +13,7 @@ import * as vscode from "vscode";
|
||||
import type { DiffViewController } from "./diffViewController";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import type { ProposalController } from "./proposalController";
|
||||
import { renderReview, renderPlain, diffBlocks, diffToHunks, type BlockOp } from "./trackChangesModel";
|
||||
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
|
||||
import { buildFingerprint } from "./anchorer";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { EditTurnResult } from "./liveTurn";
|
||||
@@ -226,11 +226,12 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
}
|
||||
|
||||
/**
|
||||
* F11 (INV-35/37): run one host edit turn and record the result as F4
|
||||
* F11/F12 (INV-35/39): run one host edit turn and record the result as F4
|
||||
* proposal(s) — a SELECTION yields one single-range proposal over the resolved
|
||||
* block-union; a DOCUMENT rewrite is `diffToHunks`'d into one single-range
|
||||
* proposal per changed hunk (reusing the F4 single-range model N times, no new
|
||||
* model). Never mutates the document (INV-10). Returns the created proposal ids.
|
||||
* block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per
|
||||
* changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged
|
||||
* `granularity:"block"` so accept reconciles attribution per word (INV-40).
|
||||
* Never mutates the document (INV-10). Returns the created proposal ids.
|
||||
*/
|
||||
async runEditAndPropose(
|
||||
document: vscode.TextDocument,
|
||||
@@ -253,9 +254,17 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
}
|
||||
const turn = await this.editTurn(instruction, full);
|
||||
const ids: string[] = [];
|
||||
for (const h of diffToHunks(full, turn.replacement)) {
|
||||
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
|
||||
// granularity — one proposal per changed block (the unit a human reviews) —
|
||||
// not per word. Each is tagged `granularity:"block"` so accept reconciles
|
||||
// attribution per word inside the block (INV-40).
|
||||
for (const h of diffToBlockHunks(full, turn.replacement)) {
|
||||
const fp = buildFingerprint(full, { start: h.start, end: h.end });
|
||||
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), { turnId, instruction });
|
||||
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), {
|
||||
turnId,
|
||||
instruction,
|
||||
granularity: "block",
|
||||
});
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
|
||||
Reference in New Issue
Block a user