feat: comments-first ask + comment→reply→offer→proposal loop (D19/D10/D8, PUC-8); sunset the input webview (spec §6.10)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+179
-7
@@ -16,6 +16,9 @@ import { addThread, appendMessage, setStatus } from "./threadModel";
|
||||
import type { VersionGuard } from "./versionGuard";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
||||
import type { EditFlow, EditTarget } from "./editFlow";
|
||||
import type { LiveProgressUi } from "./liveProgressUi";
|
||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||
|
||||
/** Test-facing snapshot of what is currently rendered for a document. */
|
||||
export interface RenderedThread {
|
||||
@@ -25,6 +28,15 @@ export interface RenderedThread {
|
||||
range: { startLine: number; endLine: number };
|
||||
}
|
||||
|
||||
/** D10/PUC-8: every human comment on a coedited doc runs this reply turn. */
|
||||
const REPLY_PROMPT = (ask: string): string =>
|
||||
"Reply conversationally (2-4 sentences) to this remark about the text, then, if the remark implies " +
|
||||
"an edit, state the edit you would make. Remark: " +
|
||||
ask;
|
||||
|
||||
/** F6.x/Task 6: the injectable low-level turn runner behind respondInThread. */
|
||||
type TurnRunner = (instruction: string, context: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
|
||||
|
||||
interface DocState {
|
||||
docPath: string;
|
||||
uri: vscode.Uri;
|
||||
@@ -41,6 +53,10 @@ export class ThreadController implements vscode.Disposable {
|
||||
private readonly controller: vscode.CommentController;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly docs = new Map<string, DocState>(); // keyed by docPath
|
||||
/** thread -> the pending machine "offer" (D8): the ask it answered, ready to become an edit. */
|
||||
private readonly offers = new WeakMap<vscode.CommentThread, { ask: string; threadId: string }>();
|
||||
/** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */
|
||||
private turnRunner: TurnRunner | undefined;
|
||||
|
||||
/** Kept as ONE object; re-assigned on every registry change so VS Code re-queries
|
||||
* (spec §6.4 v0.2.1 — reassignment is the API's only "ranges changed" signal). */
|
||||
@@ -57,6 +73,8 @@ export class ThreadController implements vscode.Disposable {
|
||||
private readonly rootDir: string | undefined,
|
||||
private readonly guard: VersionGuard,
|
||||
private readonly registry: CoeditingRegistry,
|
||||
private readonly editFlow: EditFlow,
|
||||
private readonly liveProgressUi: LiveProgressUi,
|
||||
) {
|
||||
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
|
||||
this.controller.commentingRangeProvider = this.rangeProvider;
|
||||
@@ -71,6 +89,14 @@ export class ThreadController implements vscode.Disposable {
|
||||
vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) =>
|
||||
this.setThreadStatus(t, "open"),
|
||||
),
|
||||
// D19 (spec §6.4 v0.2.1): the comments-first "Ask Claude" gesture — a
|
||||
// focused comment box, not a webview.
|
||||
vscode.commands.registerCommand("cowriting.askClaude", () => this.askClaude()),
|
||||
// D8: turn a machine "offer" thread into pending F4 proposal(s) (INV-5).
|
||||
vscode.commands.registerCommand(
|
||||
"cowriting.makeThreadEdit",
|
||||
(arg: { thread: vscode.CommentThread } | vscode.CommentThread) => this.makeEditFromThreadArg(arg),
|
||||
),
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
|
||||
// INV-10 (PUC-7): force VS Code to re-query commenting ranges on every gate
|
||||
@@ -127,6 +153,31 @@ export class ThreadController implements vscode.Disposable {
|
||||
return state;
|
||||
}
|
||||
|
||||
// ---- D19: comments-first ask -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* D19 (spec §6.4 v0.2.1): "Ask Claude" opens a focused comment box on the
|
||||
* active editor's selection, or, when there is no selection, a top-anchored
|
||||
* whole-document thread. The ask itself IS a comment — respondInThread (D10)
|
||||
* takes it from there once the human submits it via cowriting.reply.
|
||||
*/
|
||||
async askClaude(): Promise<void> {
|
||||
const ed = vscode.window.activeTextEditor;
|
||||
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
|
||||
void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first.");
|
||||
return;
|
||||
}
|
||||
if (ed.selection.isEmpty) {
|
||||
// whole-document ask → top-anchored thread (D19)
|
||||
ed.selection = new vscode.Selection(0, 0, 0, 0);
|
||||
ed.revealRange(new vscode.Range(0, 0, 0, 0));
|
||||
}
|
||||
// Spike finding: workbench.action.addComment is the only path that opens the
|
||||
// comment widget WITH its input focused (no thread.focus() API exists).
|
||||
this.controller.commentingRangeProvider = this.rangeProvider; // defensive refresh
|
||||
await vscode.commands.executeCommand("workbench.action.addComment");
|
||||
}
|
||||
|
||||
// ---- PUC-1: create on selection -------------------------------------------------
|
||||
|
||||
async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
|
||||
@@ -143,20 +194,140 @@ export class ThreadController implements vscode.Disposable {
|
||||
const fp = buildFingerprint(document.getText(), offsets);
|
||||
const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody });
|
||||
this.persist(state);
|
||||
this.renderThread(document, state, threadId, offsets, false);
|
||||
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);
|
||||
return threadId;
|
||||
}
|
||||
|
||||
// ---- PUC-2: reply / resolve -----------------------------------------------------
|
||||
|
||||
reply(r: vscode.CommentReply): void {
|
||||
const threadId = this.threadIdOf(r.thread);
|
||||
const state = this.stateOfThread(r.thread);
|
||||
if (!threadId || !state) return;
|
||||
const vsThread = r.thread;
|
||||
if (!isAuthorable(vsThread.uri.scheme) || !this.registry.isCoediting(vsThread.uri)) return;
|
||||
const document = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString());
|
||||
if (!document) return;
|
||||
const state = this.ensureState(document);
|
||||
if (this.guard.isReadOnly(state.docPath)) return;
|
||||
appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text });
|
||||
let threadId = this.threadIdOf(vsThread);
|
||||
if (threadId) {
|
||||
appendMessage(state.artifact, threadId, { author: this.currentAuthor(), 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,
|
||||
// still-unregistered CommentThread; register it now.
|
||||
const range = vsThread.range ?? new vscode.Range(0, 0, 0, 0);
|
||||
const offsets: OffsetRange = {
|
||||
start: document.offsetAt(range.start),
|
||||
end: document.offsetAt(range.end),
|
||||
};
|
||||
const fp = buildFingerprint(document.getText(), offsets);
|
||||
const created = addThread(state.artifact, fp, { author: this.currentAuthor(), body: r.text });
|
||||
threadId = created.threadId;
|
||||
state.vsThreads.set(threadId, vsThread);
|
||||
state.live.set(threadId, offsets);
|
||||
state.orphaned.set(threadId, false);
|
||||
}
|
||||
this.persist(state);
|
||||
this.refreshComments(r.thread, state, threadId);
|
||||
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/PUC-8: run one reply turn for `ask`, then append the machine's reply
|
||||
* (INV-8 onBehalfOf provenance) and flip the thread into an "offer" — ready
|
||||
* for makeThreadEdit to cut it into pending proposal(s) (D8, INV-5).
|
||||
*/
|
||||
private async respondInThread(
|
||||
vsThread: vscode.CommentThread,
|
||||
state: DocState,
|
||||
threadId: string,
|
||||
ask: string,
|
||||
): Promise<void> {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString());
|
||||
if (!doc) return;
|
||||
const contextText = this.threadContextText(doc, vsThread);
|
||||
try {
|
||||
await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…", cancellable: true },
|
||||
async (progress, token) => {
|
||||
const run = this.turnRunner ?? (await import("./liveTurn")).runEditTurn;
|
||||
const ui = this.liveProgressUi.begin(ask, progress, token);
|
||||
let turn: EditTurnResult;
|
||||
try {
|
||||
turn = await run(REPLY_PROMPT(ask), contextText, { onProgress: ui.onProgress, signal: ui.signal });
|
||||
} catch (err) {
|
||||
if (token.isCancellationRequested) return;
|
||||
throw err;
|
||||
}
|
||||
// machine reply, onBehalfOf provenance (INV-8)
|
||||
appendMessage(state.artifact, threadId, {
|
||||
author: {
|
||||
kind: "agent",
|
||||
id: "claude",
|
||||
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId, onBehalfOf: this.currentAuthor() },
|
||||
},
|
||||
body: turn.replacement,
|
||||
});
|
||||
this.persist(state);
|
||||
this.refreshComments(vsThread, state, threadId);
|
||||
vsThread.contextValue = "offer"; // when-key: commentThread == offer
|
||||
this.offers.set(vsThread, { ask, threadId });
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
void vscode.window.showErrorMessage(`Cowriting: Claude reply failed — ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The passage a ranged thread discusses, or the whole document for a top-anchored (D19) thread. */
|
||||
private threadContextText(doc: vscode.TextDocument, vsThread: vscode.CommentThread): string {
|
||||
const range = vsThread.range;
|
||||
if (range && !range.isEmpty) return doc.getText(range);
|
||||
return doc.getText();
|
||||
}
|
||||
|
||||
/** D19: a ranged thread targets its range; a top-anchored (whole-doc-ask) thread targets the document. */
|
||||
private targetOf(doc: vscode.TextDocument, vsThread: vscode.CommentThread): EditTarget {
|
||||
const range = vsThread.range;
|
||||
if (!range || range.isEmpty) return { kind: "document" };
|
||||
return { kind: "range", start: doc.offsetAt(range.start), end: doc.offsetAt(range.end) };
|
||||
}
|
||||
|
||||
// ---- D8: offer -> pending proposal(s) (INV-5) -------------------------------------
|
||||
|
||||
private async makeEditFromThreadArg(arg: { thread: vscode.CommentThread } | vscode.CommentThread): Promise<void> {
|
||||
const vsThread = "thread" in arg ? arg.thread : arg;
|
||||
await this.runMakeEdit(vsThread);
|
||||
}
|
||||
|
||||
private async runMakeEdit(vsThread: vscode.CommentThread): Promise<string[]> {
|
||||
const offer = this.offers.get(vsThread);
|
||||
if (!offer || !this.registry.isCoediting(vsThread.uri)) return [];
|
||||
const doc = await vscode.workspace.openTextDocument(vsThread.uri);
|
||||
const target = this.targetOf(doc, vsThread);
|
||||
const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask);
|
||||
vsThread.contextValue = "offer-done";
|
||||
if (ids.length) {
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: ${ids.length} pending change${ids.length === 1 ? "" : "s"} landed in the buffer — ✓ Keep / ✗ Reject there.`,
|
||||
);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Test seam (Task 6): run the offer for a thread without the UI button. */
|
||||
async makeThreadEdit(threadId: string, docPath: string): Promise<string[]> {
|
||||
const vsThread = this.docs.get(docPath)?.vsThreads.get(threadId);
|
||||
if (!vsThread) return [];
|
||||
return this.runMakeEdit(vsThread);
|
||||
}
|
||||
|
||||
/** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */
|
||||
setTurnRunnerForTest(fn: TurnRunner): void {
|
||||
this.turnRunner = fn;
|
||||
}
|
||||
|
||||
private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void {
|
||||
@@ -248,7 +419,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
threadId: string,
|
||||
offsets: OffsetRange,
|
||||
orphaned: boolean,
|
||||
): void {
|
||||
): vscode.CommentThread {
|
||||
const thread = state.artifact.threads.find((t) => t.id === threadId)!;
|
||||
const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end));
|
||||
const vsThread = this.controller.createCommentThread(
|
||||
@@ -263,6 +434,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
state.vsThreads.set(threadId, vsThread);
|
||||
state.live.set(threadId, offsets);
|
||||
state.orphaned.set(threadId, orphaned);
|
||||
return vsThread;
|
||||
}
|
||||
|
||||
private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void {
|
||||
|
||||
Reference in New Issue
Block a user