Merge pull request '#42 (SLICE-1, reach): selection-aware Ask-Claude from editor body + tab' (#49) from f12-slice1-ask-claude-menus into main
This commit was merged in pull request #49.
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
# Manual smoke — F12 document-edit flow
|
||||||
|
|
||||||
|
Covers the document-edit-flow cluster (`specs/coauthoring-document-edit-flow.md`,
|
||||||
|
#42 · #47 · #46). This file is filled in slice by slice.
|
||||||
|
|
||||||
|
## SLICE-1 — #42 (reach): selection-aware Ask-Claude from body + tab (INV-38)
|
||||||
|
|
||||||
|
Run the extension (F5) on a markdown document under the sandbox workspace.
|
||||||
|
|
||||||
|
1. **Body, with selection (PUC-2).** Select a paragraph, right-click the editor
|
||||||
|
**body**. Expect **Ask Claude to Edit Selection** in the menu (and **not**
|
||||||
|
"Edit Document"). Pick it → instruct → submit; a single proposal lands over
|
||||||
|
the selection (existing F11 behavior, unchanged).
|
||||||
|
2. **Body, no selection (PUC-1).** Clear the selection (click once), right-click
|
||||||
|
the editor **body**. Expect **Ask Claude to Edit Document** (and **not** "Edit
|
||||||
|
Selection"). Pick it → instruct → submit; the whole-document rewrite surfaces
|
||||||
|
as F4 proposal(s) in the preview.
|
||||||
|
3. **Tab, with selection (PUC-3).** With a selection active, right-click the
|
||||||
|
editor **tab**. Expect **Ask Claude to Edit Selection**, acting on that tab's
|
||||||
|
document.
|
||||||
|
4. **Tab, no selection (PUC-3).** With no selection, right-click the editor
|
||||||
|
**tab**. Expect **Ask Claude to Edit Document**, acting on **that tab's**
|
||||||
|
document — even if a *different* editor is the active one. Open two markdown
|
||||||
|
tabs A and B, make A active, right-click B's tab → Edit Document → the
|
||||||
|
proposals land on **B**, not A.
|
||||||
|
5. **Markdown-gated.** Open a non-markdown file (e.g. `.txt`). Right-click body or
|
||||||
|
tab: neither **Ask Claude to Edit Selection** nor **Edit Document** appears.
|
||||||
|
6. **Single edit path.** Both entries route through the same `runEditAndPropose`
|
||||||
|
path — there is no second edit code path (INV-38). Nothing is written to the
|
||||||
|
document or sidecar by merely invoking the menu (INV-10/20/35) until you accept.
|
||||||
|
|
||||||
|
### Pass criteria
|
||||||
|
|
||||||
|
The body and tab menus show exactly one Ask-Claude edit entry, matching the live
|
||||||
|
selection state (selection → Edit Selection; none → Edit Document); the tab
|
||||||
|
gesture targets the clicked tab's document, not the active editor; both are absent
|
||||||
|
on non-markdown docs; no console errors.
|
||||||
+17
-2
@@ -120,10 +120,20 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor/title/context": [
|
"editor/title/context": [
|
||||||
|
{
|
||||||
|
"command": "cowriting.editSelection",
|
||||||
|
"when": "editorHasSelection && resourceLangId == markdown",
|
||||||
|
"group": "1_cowriting@1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.editDocument",
|
||||||
|
"when": "!editorHasSelection && resourceLangId == markdown",
|
||||||
|
"group": "1_cowriting@1"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "cowriting.showTrackChangesPreview",
|
"command": "cowriting.showTrackChangesPreview",
|
||||||
"when": "resourceLangId == markdown",
|
"when": "resourceLangId == markdown",
|
||||||
"group": "1_cowriting"
|
"group": "1_cowriting@3"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"explorer/context": [
|
"explorer/context": [
|
||||||
@@ -136,7 +146,12 @@
|
|||||||
"editor/context": [
|
"editor/context": [
|
||||||
{
|
{
|
||||||
"command": "cowriting.editSelection",
|
"command": "cowriting.editSelection",
|
||||||
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)",
|
"when": "editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
||||||
|
"group": "1_cowriting@1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.editDocument",
|
||||||
|
"when": "!editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
||||||
"group": "1_cowriting@1"
|
"group": "1_cowriting@1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,10 +82,17 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
this.show(vscode.window.activeTextEditor?.document);
|
this.show(vscode.window.activeTextEditor?.document);
|
||||||
}),
|
}),
|
||||||
// F11: document-scoped Ask-Claude (also reused by #42's gateway). Edits the
|
// F11: document-scoped Ask-Claude (also reused by #42's reach gateways).
|
||||||
// active markdown doc; the rewrite is diffed into per-hunk F4 proposals.
|
// Edits a markdown doc; the rewrite is diffed into F4 proposals.
|
||||||
vscode.commands.registerCommand("cowriting.editDocument", () => {
|
// #42 (INV-38): the editor/title/context (tab) entry passes the clicked
|
||||||
const doc = vscode.window.activeTextEditor?.document;
|
// 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)) {
|
if (!doc || !this.isMarkdown(doc)) {
|
||||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
|
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import * as assert from "assert";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import type { CowritingApi } from "../../../src/extension";
|
||||||
|
|
||||||
|
const WS = process.env.E2E_WORKSPACE!;
|
||||||
|
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");
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||||
|
const abs = path.join(WS, rel);
|
||||||
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||||
|
fs.writeFileSync(abs, body, "utf8");
|
||||||
|
const uri = vscode.Uri.file(abs);
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
return { doc, key: uri.toString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pkg(): any {
|
||||||
|
return JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||||
|
}
|
||||||
|
function menu(id: string): Array<{ command: string; when?: string; group?: string }> {
|
||||||
|
return pkg().contributes.menus[id] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// SLICE-1 / #42 (reach): "Ask Claude to Edit" reachable from the editor BODY and
|
||||||
|
// the editor TAB, selection-aware (selection → editSelection; no selection →
|
||||||
|
// editDocument), both markdown/authorable-gated, both routing through the single
|
||||||
|
// runEditAndPropose path (INV-38). The menu `when` clauses are declarative, so we
|
||||||
|
// assert them directly; the tab-targeting behavior is exercised through the command.
|
||||||
|
suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||||
|
// PUC-1/2: editor BODY (editor/context) is selection-aware + markdown-gated.
|
||||||
|
test("editor/context offers editSelection (with selection) and editDocument (without), markdown+authorable", () => {
|
||||||
|
const m = menu("editor/context");
|
||||||
|
const sel = m.find((e) => e.command === "cowriting.editSelection");
|
||||||
|
const doc = m.find((e) => e.command === "cowriting.editDocument");
|
||||||
|
assert.ok(sel, "editSelection is in editor/context");
|
||||||
|
assert.ok(doc, "editDocument is in editor/context");
|
||||||
|
|
||||||
|
assert.match(sel!.when ?? "", /editorHasSelection/, "editSelection shows only with a selection");
|
||||||
|
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "editSelection is not gated on NO selection");
|
||||||
|
assert.match(sel!.when ?? "", /editorLangId == markdown/, "editSelection gated on markdown");
|
||||||
|
assert.match(sel!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editSelection gated authorable");
|
||||||
|
|
||||||
|
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "editDocument shows only without a selection");
|
||||||
|
assert.match(doc!.when ?? "", /editorLangId == markdown/, "editDocument gated on markdown");
|
||||||
|
assert.match(doc!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editDocument gated authorable");
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUC-3: editor TAB (editor/title/context) carries the same selection-aware pair.
|
||||||
|
test("editor/title/context offers editSelection (with selection) and editDocument (without), markdown-gated", () => {
|
||||||
|
const m = menu("editor/title/context");
|
||||||
|
const sel = m.find((e) => e.command === "cowriting.editSelection");
|
||||||
|
const doc = m.find((e) => e.command === "cowriting.editDocument");
|
||||||
|
assert.ok(sel, "editSelection is in editor/title/context");
|
||||||
|
assert.ok(doc, "editDocument is in editor/title/context");
|
||||||
|
|
||||||
|
assert.match(sel!.when ?? "", /editorHasSelection/, "tab editSelection shows only with a selection");
|
||||||
|
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "tab editSelection is not gated on NO selection");
|
||||||
|
assert.match(sel!.when ?? "", /resourceLangId == markdown/, "tab editSelection gated on markdown");
|
||||||
|
|
||||||
|
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "tab editDocument shows only without a selection");
|
||||||
|
assert.match(doc!.when ?? "", /resourceLangId == markdown/, "tab editDocument gated on markdown");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
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");
|
||||||
|
const b = await freshDoc("docs/f12-tab.md", "# Tab\n\nThe tab target paragraph to rewrite.\n");
|
||||||
|
await vscode.window.showTextDocument(a.doc);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn.
|
||||||
|
const origInput = vscode.window.showInputBox;
|
||||||
|
(vscode.window as any).showInputBox = async () => "rewrite it";
|
||||||
|
ctl.setEditTurnForTest(async () => ({
|
||||||
|
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
|
||||||
|
model: "sonnet",
|
||||||
|
sessionId: "e2e-f12-tab",
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
|
||||||
|
await settle();
|
||||||
|
} finally {
|
||||||
|
(vscode.window as any).showInputBox = origInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
);
|
||||||
|
// INV-10: proposing never mutates the document.
|
||||||
|
assert.ok(b.doc.getText().includes("tab target paragraph"), "tab doc unchanged by propose");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 origInput = vscode.window.showInputBox;
|
||||||
|
(vscode.window as any).showInputBox = async () => "rewrite it";
|
||||||
|
ctl.setEditTurnForTest(async () => ({
|
||||||
|
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
|
||||||
|
model: "sonnet",
|
||||||
|
sessionId: "e2e-f12-noarg",
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await vscode.commands.executeCommand("cowriting.editDocument");
|
||||||
|
await settle();
|
||||||
|
} finally {
|
||||||
|
(vscode.window as any).showInputBox = origInput;
|
||||||
|
}
|
||||||
|
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user