refactor: extract EditFlow (runEditAndPropose + editDocument) from the review webview ahead of its sunset (spec §6.10)

Moves runEditAndPropose, the askClaude gate/prompt/progress wrapper, the
EditTarget type, the editTurn/askEditInstruction/turnSeq state, and the
cowriting.editDocument command registration out of TrackChangesPreviewController
into a new EditFlow (src/editFlow.ts), reachable from the review webview
(delegates) and, from Task 6 on, the thread controller. extension.ts now
constructs EditFlow before the preview controller and routes
acceptAllProposals/rejectAllProposals directly to ProposalController, dropping
the preview-controller indirection. E2E suites that drove the old
trackChangesPreviewController.{setEditTurnForTest,runEditAndPropose,askEditInstruction}
seams now drive the same seams on editFlow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 08:22:44 -07:00
parent f23fc4afb3
commit ad5d34b85b
9 changed files with 300 additions and 239 deletions
+4 -162
View File
@@ -13,25 +13,9 @@ import * as vscode from "vscode";
import type { DiffViewController } from "./diffViewController";
import type { AttributionController } from "./attributionController";
import type { ProposalController } from "./proposalController";
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, landedTextOf, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer";
import { renderReview, renderPlain, diffBlocks, landedTextOf, type BlockOp } from "./trackChangesModel";
import { isAuthorable } from "./workspacePath";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi";
import { promptEditInstruction } from "./editInstructionInput";
import type { CoeditingRegistry } from "./coeditingRegistry";
/** The exact warning copy for every gated edit gesture (INV-10). */
const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first.";
/**
* F11: a host edit turn (selection/document text + instruction → rewrite).
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
* the arg is optional so existing test stubs that ignore it stay valid.
*/
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
import type { EditFlow, EditTarget } from "./editFlow";
const VIEW_TYPE = "cowriting.trackChangesPreview";
const DEBOUNCE_MS = 150;
@@ -60,34 +44,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
private readonly mode = new Map<string, "on" | "off">();
/** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88);
/**
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
*/
private editTurn: EditTurn = async (instruction, text, opts) => {
const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text, opts);
};
/**
* The instruction prompt (the multi-line split-below webview box) for BOTH the
* selection and document cases. A field so host E2E can stub it — the webview
* DOM can't run in CI (mirrors `editTurn`). Also used by the editor's
* `cowriting.editSelection` command (via this controller).
*/
askEditInstruction: (header: string) => Promise<string | undefined> = promptEditInstruction;
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0;
private nextTurnSeq(): number {
return ++this.turnSeq;
}
constructor(
private readonly diffView: DiffViewController,
private readonly extensionUri: vscode.Uri,
private readonly attribution: AttributionController,
private readonly proposals: ProposalController,
private readonly liveProgressUi: LiveProgressUi,
private readonly registry: CoeditingRegistry,
private readonly editFlow: EditFlow,
) {
this.disposables.push(
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
@@ -103,23 +66,6 @@ export class TrackChangesPreviewController implements vscode.Disposable {
}
this.show(vscode.window.activeTextEditor?.document);
}),
// F11: document-scoped Ask-Claude (also reused by #42's reach gateways).
// Edits a markdown doc; the rewrite is diffed into F4 proposals.
// #42 (INV-38): the editor/title/context (tab) entry passes the clicked
// tab's resource Uri — target THAT document, opening it if it isn't already
// an open buffer (mirrors showTrackChangesPreview's #41 resolution); the
// palette / keybinding / editor/context pass nothing → the active editor.
vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => {
const doc = uri
? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ??
(await vscode.workspace.openTextDocument(uri))
: vscode.window.activeTextEditor?.document;
if (!doc || !this.isMarkdown(doc)) {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
return;
}
void this.askClaude(doc, { kind: "document" });
}),
vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
this.proposals.onDidChangeProposals(({ uri }) => {
@@ -209,7 +155,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
} else if (m?.type === "askClaude") {
const target: EditTarget =
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
void this.askClaude(document, target);
void this.editFlow.askClaude(document, target);
} else if (m?.type === "acceptAll") {
// #46 (INV-42): batch-accept every pending proposal on this doc, then report.
void this.acceptAll(document);
@@ -246,106 +192,6 @@ export class TrackChangesPreviewController implements vscode.Disposable {
}
}
/**
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
*/
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
// INV-10: the choke point for BOTH the editDocument command and the webview's
// askClaude toolbar intent (either scope) — a non-entered doc gets the warning,
// not a turn.
if (!this.registry.isCoediting(document.uri)) {
void vscode.window.showWarningMessage(NOT_COEDITING_WARNING);
return;
}
// Both scopes use the same multi-line split-below webview box; only the header
// (and the downstream proposal logic) differs. For a selection the document
// above keeps the selection highlighted while the box is open.
const header =
target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection";
const instruction = await this.askEditInstruction(header);
if (!instruction) return;
try {
const ids = await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const ui = this.liveProgressUi.begin(instruction, progress, token);
try {
return await this.runEditAndPropose(document, target, instruction, {
onProgress: ui.onProgress,
signal: ui.signal,
});
} catch (err) {
// #60 (INV-47): a user cancel proposes nothing (the benign empty path).
if (token.isCancellationRequested) return [] as string[];
throw err;
}
},
);
if (ids.length === 0) {
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
} else {
void vscode.window.showInformationMessage(
`Cowriting: Claude proposed ${ids.length} edit${ids.length === 1 ? "" : "s"} — review ✓/✗ in the preview.`,
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
}
}
/**
* 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 `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,
target: EditTarget,
instruction: string,
opts?: RunEditTurnOptions,
): Promise<string[]> {
const full = document.getText();
// One turnId per gesture — the document case's N hunk-proposals all share it,
// so a single rewrite groups as one agent turn (parity with editSelection).
const turnId = `turn-${this.nextTurnSeq()}`;
const provenance = (turn: EditTurnResult) =>
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
if (target.kind === "range") {
const selected = full.slice(target.start, target.end);
const turn = await this.editTurn(instruction, selected, opts);
if (turn.replacement === "" || turn.replacement === selected) return [];
const fp = buildFingerprint(full, { start: target.start, end: target.end });
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
return id ? [id] : [];
}
const turn = await this.editTurn(instruction, full, opts);
const ids: string[] = [];
// #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,
granularity: "block",
});
if (id) ids.push(id);
}
return ids;
}
private onEdit(document: vscode.TextDocument): void {
const key = document.uri.toString();
if (!this.panels.has(key)) return;
@@ -496,10 +342,6 @@ export class TrackChangesPreviewController implements vscode.Disposable {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m);
}
/** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */
setEditTurnForTest(fn: EditTurn): void {
this.editTurn = fn;
}
/**
* F11 (PUC-1/7): whether the previewed doc's edit controls (Pin + Ask-Claude)
* are enabled — true only for an authorable doc. The annotations toggle is