Native surfaces migration — evolve the extension onto VS Code's native review surfaces (9-task plan, spec v0.2.1) (#72)

This commit was merged in pull request #72.
This commit is contained in:
2026-07-02 23:09:37 +00:00
parent 93eeaf13b8
commit 935fcc35ee
54 changed files with 3689 additions and 1995 deletions
+57 -30
View File
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "exports preview controller");
assert.ok(api?.threadController, "exports thread controller");
return api;
}
@@ -20,6 +20,12 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
fs.writeFileSync(abs, body, "utf8");
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
// INV-10: cowriting.editDocument now warns instead of editing a non-entered
// doc (Task 4) — enter it here (briefly making it active) so the tests below
// exercise the routing/targeting behavior, not the gate. The caller
// re-establishes whichever doc it wants active afterward.
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
return { doc, key: uri.toString() };
}
@@ -66,9 +72,17 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
// PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
// not whatever editor happens to be active (mirrors #41's clicked-doc resolution).
// Finding 1 fix (native-surfaces code review): editDocument no longer prompts via
// the removed askEditInstruction webview stub (that path threw a silent, `void`'d,
// unhandled rejection — no toast, no thread, no proposal). It now resolves/focuses
// its target document and hands off to ThreadController.askClaude() (D19), same as
// editSelection. The turn→proposal cut itself (EditFlow.runEditAndPropose against a
// {kind:"document"} target) is exercised directly, without the command, in
// f11Toolbar.test.ts / f12Accept.test.ts / f12Review.test.ts — this test's job is
// the URI-targeting/focus behavior, plus confirming the routing actually reaches
// askClaude (proving Finding 1's dead end is gone).
test("editDocument(uri) targets the clicked tab's document, not the active editor", async () => {
const api = await getApi();
const ctl = api.trackChangesPreviewController;
// Doc A is the active editor; Doc B is the "clicked tab" we pass by URI.
const a = await freshDoc("docs/f12-active.md", "# Active\n\nThe active editor paragraph.\n");
@@ -76,55 +90,68 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.window.showTextDocument(a.doc);
await settle();
// Stub the document instruction prompt (the webview can't run in CI) + the LLM turn.
const origPrompt = ctl.askEditInstruction;
ctl.askEditInstruction = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
model: "sonnet",
sessionId: "e2e-f12-tab",
}));
let askClaudeCalls = 0;
// Capture the active-editor doc INSIDE the spy, before delegating to the real
// askClaude — askClaude's own `workbench.action.addComment` side effect moves
// focus to the ephemeral comment-input widget (a `comment://` URI), which would
// make a post-hoc `activeTextEditor` check meaningless. What we're proving here
// is editDocument's OWN resolve-and-focus step ran against the right document
// before handing off.
let focusedDocAtHandoff: string | undefined;
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
api.threadController.askClaude = async () => {
askClaudeCalls++;
focusedDocAtHandoff = vscode.window.activeTextEditor?.document.uri.toString();
return origAskClaude();
};
try {
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
await settle();
} finally {
ctl.askEditInstruction = origPrompt;
api.threadController.askClaude = origAskClaude;
}
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
assert.ok(api.proposalController.listProposals(b.doc).length >= 1, "tab doc B received the document-edit proposal(s)");
assert.strictEqual(
api.proposalController.listProposals(a.doc).length,
0,
"active doc A was NOT edited — editDocument honored the tab URI",
askClaudeCalls,
1,
"editDocument(uri) reached ThreadController.askClaude — not the removed prompt stub",
);
assert.strictEqual(
focusedDocAtHandoff,
b.doc.uri.toString(),
"editDocument(uri) focused the TAB doc B, not the previously-active doc A, before handing off to askClaude",
);
// F12 (INV-48): EditorProposalController optimistically applies proposals into the
// buffer, so the proposed text is now in the tab doc B. Confirm one of the proposal
// texts is present (the tab doc was edited, not A).
assert.ok(b.doc.getText().includes("REWRITTEN"), "F12 optimistic-apply: proposed text in tab doc B");
});
// No URI arg (palette / keybinding) → fall back to the active editor.
test("editDocument() with no arg targets the active editor", async () => {
const api = await getApi();
const ctl = api.trackChangesPreviewController;
const a = await freshDoc("docs/f12-noarg.md", "# No arg\n\nThe active doc paragraph here.\n");
await vscode.window.showTextDocument(a.doc);
await settle();
const origPrompt = ctl.askEditInstruction;
ctl.askEditInstruction = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
model: "sonnet",
sessionId: "e2e-f12-noarg",
}));
let askClaudeCalls = 0;
// See the sibling test above for why this is captured inside the spy rather
// than after askClaude() returns.
let focusedDocAtHandoff: string | undefined;
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
api.threadController.askClaude = async () => {
askClaudeCalls++;
focusedDocAtHandoff = vscode.window.activeTextEditor?.document.uri.toString();
return origAskClaude();
};
try {
await vscode.commands.executeCommand("cowriting.editDocument");
await settle();
} finally {
ctl.askEditInstruction = origPrompt;
api.threadController.askClaude = origAskClaude;
}
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
assert.strictEqual(askClaudeCalls, 1, "editDocument() with no arg reached ThreadController.askClaude");
assert.strictEqual(
focusedDocAtHandoff,
a.doc.uri.toString(),
"editDocument() with no arg kept the active editor's doc active before handing off to askClaude",
);
});
});