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:
+36
-1
@@ -135,6 +135,18 @@
|
|||||||
"command": "cowriting.stopCoediting",
|
"command": "cowriting.stopCoediting",
|
||||||
"title": "Stop editing with Claude",
|
"title": "Stop editing with Claude",
|
||||||
"category": "Cowriting"
|
"category": "Cowriting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.askClaude",
|
||||||
|
"title": "Ask Claude",
|
||||||
|
"category": "Cowriting",
|
||||||
|
"icon": "$(sparkle)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.makeThreadEdit",
|
||||||
|
"title": "✦ Make this edit",
|
||||||
|
"category": "Cowriting",
|
||||||
|
"icon": "$(sparkle)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
@@ -171,6 +183,14 @@
|
|||||||
"command": "cowriting.editDocument",
|
"command": "cowriting.editDocument",
|
||||||
"when": "false"
|
"when": "false"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.askClaude",
|
||||||
|
"when": "editorLangId == markdown && cowriting.isCoediting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.makeThreadEdit",
|
||||||
|
"when": "false"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "cowriting.acceptAllProposals",
|
"command": "cowriting.acceptAllProposals",
|
||||||
"when": "editorLangId == markdown && cowriting.isCoediting"
|
"when": "editorLangId == markdown && cowriting.isCoediting"
|
||||||
@@ -194,6 +214,11 @@
|
|||||||
"when": "resourceLangId == markdown && cowriting.isCoediting",
|
"when": "resourceLangId == markdown && cowriting.isCoediting",
|
||||||
"group": "navigation@1"
|
"group": "navigation@1"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.askClaude",
|
||||||
|
"when": "resourceLangId == markdown && cowriting.isCoediting",
|
||||||
|
"group": "navigation@2"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "cowriting.markReviewed",
|
"command": "cowriting.markReviewed",
|
||||||
"when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot",
|
"when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot",
|
||||||
@@ -249,11 +274,21 @@
|
|||||||
"comments/commentThread/context": [
|
"comments/commentThread/context": [
|
||||||
{
|
{
|
||||||
"command": "cowriting.reply",
|
"command": "cowriting.reply",
|
||||||
"group": "inline",
|
"group": "inline@1",
|
||||||
"when": "commentController == cowriting.threads"
|
"when": "commentController == cowriting.threads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.makeThreadEdit",
|
||||||
|
"group": "inline@2",
|
||||||
|
"when": "commentController == cowriting.threads && commentThread == offer"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"comments/commentThread/title": [
|
"comments/commentThread/title": [
|
||||||
|
{
|
||||||
|
"command": "cowriting.makeThreadEdit",
|
||||||
|
"group": "inline@1",
|
||||||
|
"when": "commentController == cowriting.threads && commentThread == offer"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "cowriting.resolveThread",
|
"command": "cowriting.resolveThread",
|
||||||
"group": "inline",
|
"group": "inline",
|
||||||
|
|||||||
+13
-6
@@ -17,7 +17,6 @@ import type { CoeditingRegistry } from "./coeditingRegistry";
|
|||||||
import { diffToBlockHunks } from "./trackChangesModel";
|
import { diffToBlockHunks } from "./trackChangesModel";
|
||||||
import { buildFingerprint } from "./anchorer";
|
import { buildFingerprint } from "./anchorer";
|
||||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||||
import { promptEditInstruction } from "./editInstructionInput";
|
|
||||||
|
|
||||||
/** The exact warning copy for every gated edit gesture (INV-10). */
|
/** The exact warning copy for every gated edit gesture (INV-10). */
|
||||||
const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first.";
|
const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first.";
|
||||||
@@ -42,12 +41,20 @@ export class EditFlow implements vscode.Disposable {
|
|||||||
return runEditTurn(instruction, text, opts);
|
return runEditTurn(instruction, text, opts);
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* The instruction prompt (the multi-line split-below webview box) for BOTH the
|
* LEGACY (Task 6, spec §6.10): the instruction prompt used to be the
|
||||||
* selection and document cases. A field so host E2E can stub it — the webview
|
* multi-line split-below input webview (`editInstructionInput.ts`, deleted).
|
||||||
* DOM can't run in CI (mirrors `editTurn`). Also used by the editor's
|
* The real interactive ask is now ThreadController.askClaude — a focused
|
||||||
* `cowriting.editSelection` command (extension.ts).
|
* comment box (D19); `cowriting.editSelection` routes there directly. This
|
||||||
|
* field's only remaining production caller is the `cowriting.editDocument`
|
||||||
|
* command below (via `askClaude`), and the review-webview's toolbar ask —
|
||||||
|
* both die fully in Task 8. Rejects by default so any surviving real call
|
||||||
|
* fails loudly instead of silently invoking a webview that no longer
|
||||||
|
* exists; host E2E stub this field directly (mirrors `editTurn`).
|
||||||
*/
|
*/
|
||||||
askEditInstruction: (header: string) => Promise<string | undefined> = promptEditInstruction;
|
askEditInstruction: (header: string) => Promise<string | undefined> = (header) =>
|
||||||
|
Promise.reject(
|
||||||
|
new Error(`Cowriting: the "${header}" instruction input was removed — use ✦ Ask Claude instead.`),
|
||||||
|
);
|
||||||
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
|
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
|
||||||
private turnSeq = 0;
|
private turnSeq = 0;
|
||||||
private nextTurnSeq(): number {
|
private nextTurnSeq(): number {
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import * as vscode from "vscode";
|
|
||||||
import { randomBytes } from "crypto";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The multi-line instruction input for "Ask Claude to Edit" — BOTH the selection
|
|
||||||
* and the whole-document case. A small focused webview (a tall, resizable
|
|
||||||
* textarea + Send) opened in a split pane BELOW the document — not a comment
|
|
||||||
* thread, and not the top QuickInput (which is single-line only; VS Code has no
|
|
||||||
* multi-line input at the command-palette location). For a selection edit the
|
|
||||||
* document stays in the pane above with the selection still highlighted (VS
|
|
||||||
* Code's inactive-selection style), so the user can see exactly what Claude will
|
|
||||||
* edit; the caller has already captured the selection, so we never touch it.
|
|
||||||
*
|
|
||||||
* The split collapses when the input is submitted or cancelled, and focus is
|
|
||||||
* handed back to the document so the collapsing split doesn't reveal the bottom
|
|
||||||
* panel. `header` names the scope ("Ask Claude to Edit This Selection" /
|
|
||||||
* "…This Document").
|
|
||||||
*
|
|
||||||
* The webview only collects text and posts it to the host — no SDK or secret
|
|
||||||
* surface lives in it (INV-8/35); the sealed CSP allows no network. Because a
|
|
||||||
* webview CAN read its own textarea, Cancel / Escape confirms ONLY when there is
|
|
||||||
* text to lose (closing the tab is an explicit dismiss, no prompt). Resolves with
|
|
||||||
* the typed instruction, or `undefined` if cancelled / closed / left empty.
|
|
||||||
*/
|
|
||||||
export async function promptEditInstruction(header: string): Promise<string | undefined> {
|
|
||||||
// Remember the document editor we came from so we can hand focus back to it
|
|
||||||
// when the input closes — otherwise, when the empty split group below collapses,
|
|
||||||
// focus falls into the bottom panel (Output / Debug Console / …) and it pops
|
|
||||||
// open. Restoring editor focus leaves whatever panel state the user had untouched.
|
|
||||||
const source = vscode.window.activeTextEditor;
|
|
||||||
// Open a split editor group BELOW the current one and host the input there, so
|
|
||||||
// it sits under the document instead of covering it as a tab. The new group is
|
|
||||||
// empty, so disposing the panel on submit/cancel leaves it empty and VS Code
|
|
||||||
// collapses the split (the `workbench.editor.closeEmptyGroups` default). Falls
|
|
||||||
// back to a tab in the active group if the split command is unavailable.
|
|
||||||
try {
|
|
||||||
await vscode.commands.executeCommand("workbench.action.newGroupBelow");
|
|
||||||
} catch {
|
|
||||||
/* no split — the panel opens as a tab in the active group instead */
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const panel = vscode.window.createWebviewPanel(
|
|
||||||
"cowriting.askClaudeInput",
|
|
||||||
header,
|
|
||||||
{ viewColumn: vscode.ViewColumn.Active, preserveFocus: false },
|
|
||||||
{ enableScripts: true, retainContextWhenHidden: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
let settled = false;
|
|
||||||
const done = (value: string | undefined): void => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
resolve(value);
|
|
||||||
panel.dispose();
|
|
||||||
// Hand focus back to the originating document so the collapsing split
|
|
||||||
// doesn't leave focus in (and reveal) the bottom panel.
|
|
||||||
if (source) {
|
|
||||||
void vscode.window.showTextDocument(source.document, {
|
|
||||||
viewColumn: source.viewColumn ?? vscode.ViewColumn.One,
|
|
||||||
preserveFocus: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
panel.webview.onDidReceiveMessage((m: { type?: string; text?: string }) => {
|
|
||||||
const text = (m?.text ?? "").trim();
|
|
||||||
if (m?.type === "submit") {
|
|
||||||
done(text ? text : undefined);
|
|
||||||
} else if (m?.type === "cancel") {
|
|
||||||
// Confirm only if there's something to lose (we can read the textarea here).
|
|
||||||
if (!text) {
|
|
||||||
done(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void vscode.window
|
|
||||||
.showWarningMessage("Discard your Ask-Claude instruction?", { modal: true }, "Discard")
|
|
||||||
.then((pick) => {
|
|
||||||
if (pick === "Discard") done(undefined);
|
|
||||||
// else: leave the panel open so the operator can keep editing.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Closing the tab is an explicit dismiss — cancel without a prompt.
|
|
||||||
panel.onDidDispose(() => done(undefined));
|
|
||||||
panel.webview.html = htmlFor(header);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
|
||||||
}
|
|
||||||
|
|
||||||
function htmlFor(header: string): string {
|
|
||||||
const nonce = randomBytes(16).toString("base64");
|
|
||||||
// Sealed CSP: no network; inline style only; the one script is nonce-gated.
|
|
||||||
const csp = `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';`;
|
|
||||||
const title = escapeHtml(header);
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="${csp}" />
|
|
||||||
<title>${title}</title>
|
|
||||||
<style>
|
|
||||||
body { padding: 14px 16px; font-family: var(--vscode-font-family); color: var(--vscode-foreground); }
|
|
||||||
h2 { font-size: 13px; font-weight: 600; margin: 0 0 10px; }
|
|
||||||
textarea {
|
|
||||||
width: 100%; min-height: 160px; resize: vertical; box-sizing: border-box;
|
|
||||||
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
|
|
||||||
border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; padding: 8px;
|
|
||||||
font-family: var(--vscode-editor-font-family); font-size: var(--vscode-editor-font-size); line-height: 1.4;
|
|
||||||
}
|
|
||||||
textarea::placeholder { color: var(--vscode-input-placeholderForeground); }
|
|
||||||
textarea:focus { outline: 1px solid var(--vscode-focusBorder); border-color: var(--vscode-focusBorder); }
|
|
||||||
.row { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; gap: 12px; }
|
|
||||||
.hint { color: var(--vscode-descriptionForeground); font-size: 12px; }
|
|
||||||
button {
|
|
||||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
|
||||||
border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px;
|
|
||||||
}
|
|
||||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h2>${title}</h2>
|
|
||||||
<textarea id="inst" placeholder="e.g. tighten the intro, add a conclusion, and fix the heading levels" autofocus></textarea>
|
|
||||||
<div class="row">
|
|
||||||
<span class="hint">⌘↵ / Ctrl+↵ to send · Esc to cancel</span>
|
|
||||||
<button id="send">Send to Claude</button>
|
|
||||||
</div>
|
|
||||||
<script nonce="${nonce}">
|
|
||||||
const api = acquireVsCodeApi();
|
|
||||||
const ta = document.getElementById('inst');
|
|
||||||
const focus = () => ta.focus();
|
|
||||||
focus();
|
|
||||||
window.addEventListener('focus', focus);
|
|
||||||
const submit = () => api.postMessage({ type: 'submit', text: ta.value });
|
|
||||||
const cancel = () => api.postMessage({ type: 'cancel', text: ta.value });
|
|
||||||
document.getElementById('send').addEventListener('click', submit);
|
|
||||||
ta.addEventListener('keydown', (e) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); submit(); }
|
|
||||||
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
+17
-107
@@ -15,13 +15,11 @@ import { TrackChangesPreviewController } from "./trackChangesPreview";
|
|||||||
import { EditFlow } from "./editFlow";
|
import { EditFlow } from "./editFlow";
|
||||||
import { LiveProgressUi } from "./liveProgressUi";
|
import { LiveProgressUi } from "./liveProgressUi";
|
||||||
import { EditorProposalController } from "./editorProposalController";
|
import { EditorProposalController } from "./editorProposalController";
|
||||||
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
import { isAuthorable, routeEdit } from "./workspacePath";
|
||||||
import { CoeditingRegistry } from "./coeditingRegistry";
|
import { CoeditingRegistry } from "./coeditingRegistry";
|
||||||
import { ScmSurfaceController } from "./scmSurface";
|
import { ScmSurfaceController } from "./scmSurface";
|
||||||
|
|
||||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||||
/** The exact warning copy for every gated edit gesture (INV-10). */
|
|
||||||
const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first.";
|
|
||||||
|
|
||||||
export interface CowritingApi {
|
export interface CowritingApi {
|
||||||
threadController: ThreadController;
|
threadController: ThreadController;
|
||||||
@@ -148,8 +146,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
// F5 (INV-16): one shared guard — newer-major sidecars are read-only, one
|
// F5 (INV-16): one shared guard — newer-major sidecars are read-only, one
|
||||||
// warning per doc across the three co-owning controllers.
|
// warning per doc across the three co-owning controllers.
|
||||||
const versionGuard = new VersionGuard(sidecarRouter);
|
const versionGuard = new VersionGuard(sidecarRouter);
|
||||||
const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry);
|
|
||||||
context.subscriptions.push(threadController);
|
|
||||||
|
|
||||||
// --- F3: live attribution (Feature #6) ---
|
// --- F3: live attribution (Feature #6) ---
|
||||||
const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry);
|
const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry);
|
||||||
@@ -170,10 +166,17 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
// `cowriting.editDocument` command, the instruction-prompt/progress-wrapped
|
// `cowriting.editDocument` command, the instruction-prompt/progress-wrapped
|
||||||
// "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut
|
// "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut
|
||||||
// (`runEditAndPropose`, INV-39/40). Constructed BEFORE the review preview (which
|
// (`runEditAndPropose`, INV-39/40). Constructed BEFORE the review preview (which
|
||||||
// delegates to it) and reachable from the thread controller too (Task 6). ---
|
// delegates to it) and BEFORE the thread controller, which now consumes it too
|
||||||
|
// (Task 6: the comment-loop's "make this edit" offer calls runEditAndPropose). ---
|
||||||
const editFlow = new EditFlow(proposalController, attributionController, liveProgressUi, coeditingRegistry);
|
const editFlow = new EditFlow(proposalController, attributionController, liveProgressUi, coeditingRegistry);
|
||||||
context.subscriptions.push(editFlow);
|
context.subscriptions.push(editFlow);
|
||||||
|
|
||||||
|
// --- F2/F10 (Task 6, D19/D10/D8): comments-first ask + the comment→reply→
|
||||||
|
// offer→proposal loop. Consumes editFlow (offer → pending proposals) and
|
||||||
|
// liveProgressUi (the shared "asking Claude…" notification + output channel). ---
|
||||||
|
const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry, editFlow, liveProgressUi);
|
||||||
|
context.subscriptions.push(threadController);
|
||||||
|
|
||||||
// --- F7/F10: the review preview is the single interactive review surface ---
|
// --- F7/F10: the review preview is the single interactive review surface ---
|
||||||
// Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline,
|
// Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline,
|
||||||
// INV-20). Constructed AFTER attribution (reads F3 spans), proposals (routes
|
// INV-20). Constructed AFTER attribution (reads F3 spans), proposals (routes
|
||||||
@@ -307,109 +310,16 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// F3 SLICE-5 → F4 SLICE-4: the live turn — selection + instruction → one
|
// Task 6 (D19): the comments-first ask replaces the old inline turn
|
||||||
// claude-code SDK turn (liveTurn.ts, INV-8) → a PENDING PROPOSAL (F4,
|
// plumbing here — the ask now IS a comment. threadController.askClaude()
|
||||||
// INV-10); the applyAgentEdit seam (INV-9) now fires only on accept.
|
// opens a focused comment box on the current selection (or, with no
|
||||||
|
// selection, a top-anchored whole-document thread); the comment→reply→
|
||||||
|
// offer→proposal loop (ThreadController.respondInThread/makeThreadEdit)
|
||||||
|
// takes it from there. Kept as its own command (rather than folded into
|
||||||
|
// cowriting.edit) for the host E2E harness and the internal seams.
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.commands.registerCommand("cowriting.editSelection", async () => {
|
vscode.commands.registerCommand("cowriting.editSelection", async () => {
|
||||||
const editor = vscode.window.activeTextEditor;
|
await threadController.askClaude();
|
||||||
// F8: authoring works on any file: or untitled: doc (the router decides
|
|
||||||
// where its artifact is stored). Each failure still names its real reason
|
|
||||||
// (no editor / no selection / a non-{file,untitled} read-only view) — not
|
|
||||||
// always "select some text" (#24's per-condition messaging).
|
|
||||||
const reason = selectionRejection({
|
|
||||||
hasEditor: !!editor,
|
|
||||||
selectionEmpty: editor?.selection.isEmpty ?? true,
|
|
||||||
scheme: editor?.document.uri.scheme ?? "",
|
|
||||||
});
|
|
||||||
if (reason) {
|
|
||||||
void vscode.window.showWarningMessage(reason);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!editor) return; // unreachable once reason is null, but narrows the type
|
|
||||||
const document = editor.document;
|
|
||||||
// INV-10: a non-entered doc gets the gate warning, not a turn.
|
|
||||||
if (!coeditingRegistry.isCoediting(document.uri)) {
|
|
||||||
void vscode.window.showWarningMessage(NOT_COEDITING_WARNING);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const selection = editor.selection; // non-empty (selectionRejection guaranteed it)
|
|
||||||
// Capture the selection text + anchor BEFORE prompting — the inline prompt
|
|
||||||
// moves the cursor to the document top (where its box anchors), which would
|
|
||||||
// otherwise collapse the selection we act on (spec §6.5 PUC-1: anchor
|
|
||||||
// captured pre-turn so mid-turn edits can't skew it).
|
|
||||||
const selectedText = document.getText(selection);
|
|
||||||
const fp = buildFingerprint(document.getText(), {
|
|
||||||
start: document.offsetAt(selection.start),
|
|
||||||
end: document.offsetAt(selection.end),
|
|
||||||
});
|
|
||||||
// The instruction prompt is the multi-line split-below webview box (shared
|
|
||||||
// with the document case, via EditFlow); the document above keeps the
|
|
||||||
// selection highlighted while it's open. selectedText/fp were captured
|
|
||||||
// above, so moving focus to the box doesn't affect what we edit.
|
|
||||||
const instruction = await editFlow.askEditInstruction("Ask Claude to Edit This Selection");
|
|
||||||
if (!instruction) return;
|
|
||||||
const turnId = `turn-${Date.now().toString(36)}`;
|
|
||||||
try {
|
|
||||||
await vscode.window.withProgress(
|
|
||||||
{
|
|
||||||
location: vscode.ProgressLocation.Notification,
|
|
||||||
title: "Cowriting: asking Claude…",
|
|
||||||
cancellable: true,
|
|
||||||
},
|
|
||||||
async (progress, token) => {
|
|
||||||
const { runEditTurn } = await import("./liveTurn");
|
|
||||||
const ui = liveProgressUi.begin(instruction, progress, token);
|
|
||||||
let turn;
|
|
||||||
try {
|
|
||||||
turn = await runEditTurn(instruction, selectedText, {
|
|
||||||
onProgress: ui.onProgress,
|
|
||||||
signal: ui.signal,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// #60 (INV-47): a user cancel surfaces as "cancelled", not a failure.
|
|
||||||
if (token.isCancellationRequested) {
|
|
||||||
void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (turn.replacement === "") {
|
|
||||||
void vscode.window.showWarningMessage(
|
|
||||||
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (turn.replacement === selectedText) {
|
|
||||||
void vscode.window.showInformationMessage(
|
|
||||||
"Cowriting: Claude proposed no change to the selection.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// F4 (INV-10): the turn ends in a PROPOSAL, not a buffer mutation.
|
|
||||||
// The seam now fires only on accept (ProposalController, INV-9).
|
|
||||||
const id = await proposalController.propose(
|
|
||||||
document,
|
|
||||||
fp,
|
|
||||||
turn.replacement,
|
|
||||||
{
|
|
||||||
kind: "agent",
|
|
||||||
id: "claude",
|
|
||||||
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId },
|
|
||||||
},
|
|
||||||
{ turnId, instruction },
|
|
||||||
);
|
|
||||||
if (id) {
|
|
||||||
void vscode.window.showInformationMessage(
|
|
||||||
"Cowriting: Claude proposed an edit — review the diff at the highlighted range (✓ accept / ✗ reject).",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+179
-7
@@ -16,6 +16,9 @@ import { addThread, appendMessage, setStatus } from "./threadModel";
|
|||||||
import type { VersionGuard } from "./versionGuard";
|
import type { VersionGuard } from "./versionGuard";
|
||||||
import { isAuthorable } from "./workspacePath";
|
import { isAuthorable } from "./workspacePath";
|
||||||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
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. */
|
/** Test-facing snapshot of what is currently rendered for a document. */
|
||||||
export interface RenderedThread {
|
export interface RenderedThread {
|
||||||
@@ -25,6 +28,15 @@ export interface RenderedThread {
|
|||||||
range: { startLine: number; endLine: number };
|
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 {
|
interface DocState {
|
||||||
docPath: string;
|
docPath: string;
|
||||||
uri: vscode.Uri;
|
uri: vscode.Uri;
|
||||||
@@ -41,6 +53,10 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
private readonly controller: vscode.CommentController;
|
private readonly controller: vscode.CommentController;
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
private readonly docs = new Map<string, DocState>(); // keyed by docPath
|
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
|
/** 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). */
|
* (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 rootDir: string | undefined,
|
||||||
private readonly guard: VersionGuard,
|
private readonly guard: VersionGuard,
|
||||||
private readonly registry: CoeditingRegistry,
|
private readonly registry: CoeditingRegistry,
|
||||||
|
private readonly editFlow: EditFlow,
|
||||||
|
private readonly liveProgressUi: LiveProgressUi,
|
||||||
) {
|
) {
|
||||||
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
|
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
|
||||||
this.controller.commentingRangeProvider = this.rangeProvider;
|
this.controller.commentingRangeProvider = this.rangeProvider;
|
||||||
@@ -71,6 +89,14 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) =>
|
vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) =>
|
||||||
this.setThreadStatus(t, "open"),
|
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.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||||
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
|
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
|
||||||
// INV-10 (PUC-7): force VS Code to re-query commenting ranges on every gate
|
// 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;
|
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 -------------------------------------------------
|
// ---- PUC-1: create on selection -------------------------------------------------
|
||||||
|
|
||||||
async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
|
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 fp = buildFingerprint(document.getText(), offsets);
|
||||||
const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody });
|
const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody });
|
||||||
this.persist(state);
|
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;
|
return threadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- PUC-2: reply / resolve -----------------------------------------------------
|
// ---- PUC-2: reply / resolve -----------------------------------------------------
|
||||||
|
|
||||||
reply(r: vscode.CommentReply): void {
|
reply(r: vscode.CommentReply): void {
|
||||||
const threadId = this.threadIdOf(r.thread);
|
const vsThread = r.thread;
|
||||||
const state = this.stateOfThread(r.thread);
|
if (!isAuthorable(vsThread.uri.scheme) || !this.registry.isCoediting(vsThread.uri)) return;
|
||||||
if (!threadId || !state) 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;
|
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.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 {
|
private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void {
|
||||||
@@ -248,7 +419,7 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
threadId: string,
|
threadId: string,
|
||||||
offsets: OffsetRange,
|
offsets: OffsetRange,
|
||||||
orphaned: boolean,
|
orphaned: boolean,
|
||||||
): void {
|
): vscode.CommentThread {
|
||||||
const thread = state.artifact.threads.find((t) => t.id === threadId)!;
|
const thread = state.artifact.threads.find((t) => t.id === threadId)!;
|
||||||
const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end));
|
const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end));
|
||||||
const vsThread = this.controller.createCommentThread(
|
const vsThread = this.controller.createCommentThread(
|
||||||
@@ -263,6 +434,7 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
state.vsThreads.set(threadId, vsThread);
|
state.vsThreads.set(threadId, vsThread);
|
||||||
state.live.set(threadId, offsets);
|
state.live.set(threadId, offsets);
|
||||||
state.orphaned.set(threadId, orphaned);
|
state.orphaned.set(threadId, orphaned);
|
||||||
|
return vsThread;
|
||||||
}
|
}
|
||||||
|
|
||||||
private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void {
|
private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void {
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ suite("coediting registry (PUC-7)", () => {
|
|||||||
// rendered thread (not the sidecar-backed data); re-entering restores it.
|
// rendered thread (not the sidecar-backed data); re-entering restores it.
|
||||||
test("non-entered doc gets no surfaces; exit detaches; re-enter restores threads", async () => {
|
test("non-entered doc gets no surfaces; exit detaches; re-enter restores threads", async () => {
|
||||||
const api = await activateApi();
|
const api = await activateApi();
|
||||||
|
// Task 6 (D10): createThreadOnSelection now also fires the respond-in-
|
||||||
|
// thread turn once the doc is coediting — stub it off (not under test
|
||||||
|
// here; this suite asserts render/hide/restore, not the reply loop).
|
||||||
|
api.threadController.setTurnRunnerForTest(async () => {
|
||||||
|
throw new Error("coediting suite: reply-loop turn stubbed off");
|
||||||
|
});
|
||||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# a\n\npara\n" });
|
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# a\n\npara\n" });
|
||||||
const ed = await vscode.window.showTextDocument(doc);
|
const ed = await vscode.window.showTextDocument(doc);
|
||||||
// not entered → no thread creation
|
// not entered → no thread creation
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import * as assert from "assert";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { activateApi, settleUntil } from "./helpers";
|
||||||
|
|
||||||
|
// Task 6 (D19/D10/D8, PUC-8): the comments-first ask + comment→reply→offer→
|
||||||
|
// proposal loop, with a stubbed reply turn (no real @cline/sdk call). Runs on
|
||||||
|
// an untitled buffer (F8 routes it to the global sidecar) so this suite has no
|
||||||
|
// workspace-folder dependency.
|
||||||
|
suite("comment → reply → offer → proposal (PUC-8, D10/D19)", () => {
|
||||||
|
test("reply on a coedited doc summons a machine reply + offer; accept lands pending proposals", async () => {
|
||||||
|
const api = await activateApi();
|
||||||
|
const doc = await vscode.workspace.openTextDocument({
|
||||||
|
language: "markdown",
|
||||||
|
content: "# T\n\nThe quick brown fox jumps over the lazy dog paragraph.\n",
|
||||||
|
});
|
||||||
|
const ed = await vscode.window.showTextDocument(doc);
|
||||||
|
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||||
|
api.threadController.setTurnRunnerForTest(async () => ({
|
||||||
|
replacement: "I would tighten this sentence.",
|
||||||
|
model: "stub",
|
||||||
|
sessionId: "s1",
|
||||||
|
}));
|
||||||
|
api.editFlow.setEditTurnForTest(async () => ({
|
||||||
|
replacement: "The quick fox jumps the lazy dog.",
|
||||||
|
model: "stub",
|
||||||
|
sessionId: "s1",
|
||||||
|
}));
|
||||||
|
ed.selection = new vscode.Selection(2, 0, 2, 20);
|
||||||
|
const threadId = (await api.threadController.createThreadOnSelection("tighten this"))!;
|
||||||
|
// reply-loop fires on the human message; wait for the machine message to persist
|
||||||
|
await settleUntil(() => {
|
||||||
|
const t = api.sidecarRouter.load(api.proposalController.keyFor(doc))?.threads.find((x) => x.id === threadId);
|
||||||
|
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
|
||||||
|
}, 10000);
|
||||||
|
const ids = await api.threadController.makeThreadEdit(threadId, api.proposalController.keyFor(doc));
|
||||||
|
assert.ok(ids.length >= 1);
|
||||||
|
assert.ok(api.proposalController.listProposals(doc).length >= 1); // pending, INV-5
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a comment on a NON-coedited doc summons nothing (INV-10)", async () => {
|
||||||
|
const api = await activateApi();
|
||||||
|
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "plain\n" });
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
const id = await api.threadController.createThreadOnSelection("hello");
|
||||||
|
assert.strictEqual(id, undefined); // gate refuses thread creation entirely
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,6 +57,13 @@ suite("F5 cross-rung round-trip (host E2E)", () => {
|
|||||||
const DOC = "docs/crossrung.md";
|
const DOC = "docs/crossrung.md";
|
||||||
const doc = await open(DOC);
|
const doc = await open(DOC);
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
|
// Task 6 (D10): createThreadOnSelection also fires the respond-in-thread
|
||||||
|
// turn now — stub it off so it can't race the forge stand-in's reply
|
||||||
|
// below (this test asserts an EXACT 2-message sequence: editor note +
|
||||||
|
// forge reply).
|
||||||
|
api.threadController.setTurnRunnerForTest(async () => {
|
||||||
|
throw new Error("crossrung suite: reply-loop turn stubbed off");
|
||||||
|
});
|
||||||
const target = "portable record";
|
const target = "portable record";
|
||||||
const start = doc.getText().indexOf(target);
|
const start = doc.getText().indexOf(target);
|
||||||
const editor = vscode.window.activeTextEditor!;
|
const editor = vscode.window.activeTextEditor!;
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)",
|
|||||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||||
await settle();
|
await settle();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
|
// Task 6 (D10): createThreadOnSelection (used later in this test) also
|
||||||
|
// fires the respond-in-thread turn now — stub it off (this "no LLM" suite
|
||||||
|
// asserts the programmatic propose/accept seam, not the reply loop).
|
||||||
|
api.threadController.setTurnRunnerForTest(async () => {
|
||||||
|
throw new Error("out-of-workspace suite: reply-loop turn stubbed off");
|
||||||
|
});
|
||||||
const key = uri.toString();
|
const key = uri.toString();
|
||||||
|
|
||||||
const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1");
|
const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1");
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ async function getApi(): Promise<CowritingApi> {
|
|||||||
return api;
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task 6 (D10): every human comment on a coedited doc now also fires the
|
||||||
|
// respond-in-thread turn (createThreadOnSelection/reply). This suite predates
|
||||||
|
// that loop and asserts exact message sequences unrelated to it — stub the
|
||||||
|
// turn to reject so the loop runs harmlessly (no real @cline/sdk call, and no
|
||||||
|
// extra machine message lands in the sidecar this suite's assertions read).
|
||||||
|
function stubReplyLoop(api: CowritingApi): void {
|
||||||
|
api.threadController.setTurnRunnerForTest(async () => {
|
||||||
|
throw new Error("F2 suite: reply-loop turn stubbed off (not under test here)");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Reaches the controller's internal docs map for reply/resolve handlers (which
|
// Reaches the controller's internal docs map for reply/resolve handlers (which
|
||||||
// expect a live vscode.CommentThread). Spec §6.8 "drive the ThreadController and
|
// expect a live vscode.CommentThread). Spec §6.8 "drive the ThreadController and
|
||||||
// assert Comments-controller state" fallback.
|
// assert Comments-controller state" fallback.
|
||||||
@@ -67,6 +78,7 @@ suite("F2 region-anchored threads (host E2E)", () => {
|
|||||||
test("create on selection persists a thread sidecar", async () => {
|
test("create on selection persists a thread sidecar", async () => {
|
||||||
const doc = await openSample();
|
const doc = await openSample();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
|
stubReplyLoop(api);
|
||||||
const start = doc.getText().indexOf("quick brown fox");
|
const start = doc.getText().indexOf("quick brown fox");
|
||||||
const editor = vscode.window.activeTextEditor!;
|
const editor = vscode.window.activeTextEditor!;
|
||||||
editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length));
|
editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length));
|
||||||
@@ -89,6 +101,7 @@ suite("F2 region-anchored threads (host E2E)", () => {
|
|||||||
|
|
||||||
test("reply appends and resolve flips status, both persisted", async () => {
|
test("reply appends and resolve flips status, both persisted", async () => {
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
|
stubReplyLoop(api);
|
||||||
const art0 = readSidecar();
|
const art0 = readSidecar();
|
||||||
const threadId = art0.threads[0].id;
|
const threadId = art0.threads[0].id;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user