fix: route document asks to comment-first askClaude (plan T6 Step 4); machine-author guard + offer cleanup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 09:04:32 -07:00
parent 75f4a3cc20
commit c9975ba9e6
5 changed files with 177 additions and 70 deletions
+25 -32
View File
@@ -1,13 +1,15 @@
/**
* EditFlow — the host edit-turn flow (F11/F12), extracted from the review
* webview controller ahead of its sunset (native-surfaces migration, spec
* §6.10). Owns the `cowriting.editDocument` command, the instruction prompt +
* progress-wrapped "ask Claude" gesture (INV-10 gate, INV-8 host-only LLM
* surface), and the low-level turn→proposal(s) cut (`runEditAndPropose`,
* INV-39/40). Never mutates the document directly — every turn lands as one or
* more F4 proposals via `ProposalController.propose` (INV-10). Reachable from
* both the review webview (delegates here) and, from Task 6 on, the thread
* controller — vscode-API-only, no webview state.
* §6.10). Owns the instruction prompt + progress-wrapped "ask Claude" gesture
* (INV-10 gate, INV-8 host-only LLM surface), and the low-level turn→proposal(s)
* cut (`runEditAndPropose`, INV-39/40). Never mutates the document directly —
* every turn lands as one or more F4 proposals via `ProposalController.propose`
* (INV-10). `runEditAndPropose` is reachable from the review webview (via this
* class's `askClaude`) and, from Task 6 on, the thread controller
* (`ThreadController.runMakeEdit`) — vscode-API-only, no webview state. The
* `cowriting.editDocument` command lives in extension.ts (Finding-1 fix) so it
* can hand off to `ThreadController.askClaude()`.
*/
import * as vscode from "vscode";
import type { ProposalController } from "./proposalController";
@@ -67,38 +69,29 @@ export class EditFlow implements vscode.Disposable {
private readonly liveProgressUi: LiveProgressUi,
private readonly registry: CoeditingRegistry,
) {
this.disposables.push(
// 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 || doc.languageId !== "markdown") {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
return;
}
void this.askClaude(doc, { kind: "document" });
}),
);
// Code-review fix (Finding 1, session native-surfaces-exec): the
// `cowriting.editDocument` command used to be registered here, calling
// `this.askClaude(doc, {kind:"document"})` — which prompts via the (now
// rejecting-stub) `askEditInstruction`, a silent dead end. That command is now
// registered in extension.ts, AFTER ThreadController exists, and hands off to
// `ThreadController.askClaude()` (the comments-first ask, D19) instead. This
// class's `askClaude`/`askEditInstruction` remain: the review webview's
// toolbar ask (`trackChangesPreview.ts`) still delegates to them, slated for
// deletion alongside that webview in Task 8.
}
/**
* 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`. Called
* both by the `cowriting.editDocument` command above and (via delegation) by
* the review webview's `askClaude` toolbar intent (either scope).
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`. As of the
* Finding-1 fix, its only production caller is the review webview's `askClaude`
* toolbar intent (either scope) — `cowriting.editDocument`/`cowriting.editSelection`
* now delegate to `ThreadController.askClaude()` instead (D19). Both die together
* in Task 8 when the webview is sunset.
*/
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.
// INV-10: the choke point for 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;
+34 -2
View File
@@ -323,11 +323,43 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
}),
);
// Code-review fix (Finding 1, session native-surfaces-exec): `cowriting.editDocument`
// used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompts via
// `askEditInstruction` — a rejecting stub since Task 6 sunset the input webview
// (spec §6.10). Reached with no selection (the common case for `cowriting.edit`),
// that was a SILENT dead end: the reject fired before EditFlow.askClaude's own try/
// catch and was `void`'d, so no toast, no thread, nothing. This command now resolves
// its target document exactly as before (a tab's clicked URI, falling back to the
// active editor), focuses it, and hands off to the SAME comments-first ask as
// `cowriting.editSelection` — `ThreadController.askClaude()` top-anchors a
// whole-document ask when the (now-focused) editor's selection is empty (D19). Kept
// registered + hidden from the palette for the host E2E harness and #42's
// tab-targeting behavior; `EditFlow.runEditAndPropose` (untouched) stays the E2E seam
// for driving a turn without the native comment UI — see f12Reach.test.ts /
// f11Toolbar.test.ts / f12Accept.test.ts / f12Review.test.ts.
context.subscriptions.push(
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 || doc.languageId !== "markdown") {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
return;
}
if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) {
await vscode.window.showTextDocument(doc);
}
await threadController.askClaude();
}),
);
// The single user-facing "Ask Claude to Edit" gesture (one command, one
// keybinding, one menu entry). It routes to the selection flow (editSelection)
// or the whole-document flow (editDocument) by selection/context — see
// routeEdit. The two underlying commands stay registered for the host E2E
// harness and the internal seams, but are hidden from the palette.
// routeEdit. Both underlying commands now end at ThreadController.askClaude()
// (comments-first ask, D19); they stay registered for the host E2E harness and
// the internal seams, but are hidden from the palette.
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => {
const editor = vscode.window.activeTextEditor;
+25 -7
View File
@@ -192,12 +192,17 @@ export class ThreadController implements vscode.Disposable {
end: document.offsetAt(editor.selection.end),
};
const fp = buildFingerprint(document.getText(), offsets);
const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody });
const author = this.currentAuthor();
const { threadId } = addThread(state.artifact, fp, { author, body: firstBody });
this.persist(state);
const vsThread = this.renderThread(document, state, threadId, offsets, false);
// D10: the first message of a new thread is also "a comment on a coedited
// doc" — run the same respond loop as a reply.
void this.respondInThread(vsThread, state, threadId, firstBody);
// doc" — run the same respond loop as a reply. Finding 2 guard: only for a
// human-authored message (never re-trigger the loop off the machine's own
// words — see reply() below for the matching guard).
if (author.kind !== "agent") {
void this.respondInThread(vsThread, state, threadId, firstBody);
}
return threadId;
}
@@ -210,9 +215,10 @@ export class ThreadController implements vscode.Disposable {
if (!document) return;
const state = this.ensureState(document);
if (this.guard.isReadOnly(state.docPath)) return;
const author = this.currentAuthor();
let threadId = this.threadIdOf(vsThread);
if (threadId) {
appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text });
appendMessage(state.artifact, threadId, { author, body: r.text });
} else {
// D10/D19: a brand-new thread created via the native "+"/comment-widget
// gesture (not via createThreadOnSelection) — VS Code hands us a real,
@@ -223,7 +229,7 @@ export class ThreadController implements vscode.Disposable {
end: document.offsetAt(range.end),
};
const fp = buildFingerprint(document.getText(), offsets);
const created = addThread(state.artifact, fp, { author: this.currentAuthor(), body: r.text });
const created = addThread(state.artifact, fp, { author, body: r.text });
threadId = created.threadId;
state.vsThreads.set(threadId, vsThread);
state.live.set(threadId, offsets);
@@ -231,8 +237,14 @@ export class ThreadController implements vscode.Disposable {
}
this.persist(state);
this.refreshComments(vsThread, state, threadId);
// D10: every human comment on a coedited doc runs a turn.
void this.respondInThread(vsThread, state, threadId, r.text);
// D10: every HUMAN comment on a coedited doc runs a turn. Finding 2 guard:
// `cowriting.reply` is the ingress for both a genuine human reply and (in
// principle) a re-dispatched machine message — never loop the machine's own
// reply back through respondInThread (it would double-turn on Claude's words
// and re-flip an already-"offer" thread).
if (author.kind !== "agent") {
void this.respondInThread(vsThread, state, threadId, r.text);
}
}
/**
@@ -310,6 +322,12 @@ export class ThreadController implements vscode.Disposable {
const target = this.targetOf(doc, vsThread);
const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask);
vsThread.contextValue = "offer-done";
// Finding 3 (seam hygiene): drop the offer once it's spent — otherwise the
// `makeThreadEdit(threadId, docPath)` test seam (or a stray second UI click,
// now dead per contextValue but still a valid direct call) could re-run the
// same edit a second time. A future offer on this thread is a fresh
// respondInThread() call, which re-populates the WeakMap entry.
this.offers.delete(vsThread);
if (ids.length) {
void vscode.window.showInformationMessage(
`Cowriting: ${ids.length} pending change${ids.length === 1 ? "" : "s"} landed in the buffer — ✓ Keep / ✗ Reject there.`,