From 9c3770d26a1a8e3c72da08f8250904f1337930b1 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sat, 13 Jun 2026 07:26:14 -0700 Subject: [PATCH] #42 (SLICE-1, reach): selection-aware Ask-Claude from editor body + tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document-edit flow SLICE-1 (specs/coauthoring-document-edit-flow.md §7.2, INV-38): make "Ask Claude to Edit" reachable from the editor body AND the editor tab, selection-aware — a selection routes to editSelection, no selection to editDocument — both gated to markdown/authorable docs, both flowing through the single runEditAndPropose path (no divergent edit code). - package.json: add editSelection + editDocument to editor/context (selection- aware, markdown + file/untitled) and editor/title/context (selection-aware, resourceLangId == markdown). Markdown-gate the existing editor/context editSelection entry to match (its command handler is unchanged; the palette still reaches any authorable doc — see transcript Deferred decisions). - trackChangesPreview.ts: cowriting.editDocument accepts the clicked tab's resource Uri (editor/title/context), targeting THAT document (opening it if needed) and falling back to the active editor when invoked with no arg — mirroring showTrackChangesPreview's #41 clicked-doc resolution. - E2E (test/e2e/suite/f12Reach.test.ts): menu entries present, selection-aware, markdown-gated; editDocument(uri) targets the tab doc not the active editor; no-arg falls back to the active editor. - docs/MANUAL-SMOKE-F12.md: SLICE-1 reach smoke steps. 208 unit + 65/5 host E2E green. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/MANUAL-SMOKE-F12.md | 37 +++++++++ package.json | 19 ++++- src/trackChangesPreview.ts | 15 +++- test/e2e/suite/f12Reach.test.ts | 134 ++++++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 docs/MANUAL-SMOKE-F12.md create mode 100644 test/e2e/suite/f12Reach.test.ts diff --git a/docs/MANUAL-SMOKE-F12.md b/docs/MANUAL-SMOKE-F12.md new file mode 100644 index 0000000..f665554 --- /dev/null +++ b/docs/MANUAL-SMOKE-F12.md @@ -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. diff --git a/package.json b/package.json index 969c54a..1e595e2 100644 --- a/package.json +++ b/package.json @@ -120,10 +120,20 @@ } ], "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", "when": "resourceLangId == markdown", - "group": "1_cowriting" + "group": "1_cowriting@3" } ], "explorer/context": [ @@ -136,7 +146,12 @@ "editor/context": [ { "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" }, { diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index 680b490..f8a9fc7 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -82,10 +82,17 @@ export class TrackChangesPreviewController implements vscode.Disposable { } this.show(vscode.window.activeTextEditor?.document); }), - // F11: document-scoped Ask-Claude (also reused by #42's gateway). Edits the - // active markdown doc; the rewrite is diffed into per-hunk F4 proposals. - vscode.commands.registerCommand("cowriting.editDocument", () => { - const doc = vscode.window.activeTextEditor?.document; + // 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 || !this.isMarkdown(doc)) { void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it."); return; diff --git a/test/e2e/suite/f12Reach.test.ts b/test/e2e/suite/f12Reach.test.ts new file mode 100644 index 0000000..9e93c10 --- /dev/null +++ b/test/e2e/suite/f12Reach.test.ts @@ -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 { + 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"); + }); +});