#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:
+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>).
|
||||
|
||||
Reference in New Issue
Block a user