From 03b61ed43ebf3dcb2413bcb8f9cc8ea88125b49b Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 12 Jun 2026 13:53:14 -0700 Subject: [PATCH] =?UTF-8?q?feat(f11):=20SLICE-4=20=E2=80=94=20single=20ada?= =?UTF-8?q?ptive=20Ask-Claude=20button=20+=20selection=20mapping=20(#43,?= =?UTF-8?q?=20INV-37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one Ask-Claude toolbar button now adapts: its label flips on selectionchange (Edit Selection when live text is selected in the preview, Edit Document otherwise), and a click resolves the preview selection to a SOURCE range via the nearest data-src ancestors (INV-36) — the webview's sole mapping duty. A selection touching no live-source block falls back to document scope. Per spec §6.5 PUC-2/3 / §7.2 SLICE-4. - webview (media/preview.ts): nearestSrc() DOM walk + selectionSrcRange() block-union; updateAskLabel() on selectionchange + after each render; the adaptive click posts { askClaude, scope:"selection", start, end } or falls back to document scope. Sealed (INV-21): reads data-src only, posts intent. - host: runEditAndPropose's range branch (already shared from SLICE-3) records one single-range F4 proposal over the resolved block-union. - host E2E: stubbed selection turn → exactly one proposal over the resolved range (turn receives exactly the selected source; replaced == the range; doc untouched); an unchanged replacement proposes nothing. (The webview DOM selection→data-src lookup is sealed-sandbox → manual smoke, spec §6.8.) 205 unit + 51 host E2E green. Co-Authored-By: Claude Opus 4.8 (1M context) --- media/preview.ts | 49 ++++++++++++++++++++++++++++--- test/e2e/suite/f11Toolbar.test.ts | 43 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/media/preview.ts b/media/preview.ts index ee636b3..2219251 100644 --- a/media/preview.ts +++ b/media/preview.ts @@ -39,11 +39,51 @@ pinEl?.addEventListener("click", () => { vscodeApi.postMessage({ type: "pinBaseline" }); }); -// F11 (SLICE-3): Ask Claude to Edit Document — post the document-scope intent; the -// host prompts for the instruction + runs the turn (INV-8). SLICE-4 makes this -// button adaptive (Edit Selection when text is selected in the preview). +// F11 (SLICE-4): the single adaptive Ask-Claude button. Its label flips on +// `selectionchange` (Edit Selection when live text is selected in the preview, +// Edit Document otherwise), and a click resolves the selection to a SOURCE range +// via the nearest `data-src` ancestors (INV-36) — the webview's sole mapping +// duty. A selection that resolves to no live block falls back to document scope. + +/** Walk up from a DOM node to the nearest block carrying data-src offsets (INV-36). */ +function nearestSrc(node: Node | null): HTMLElement | null { + let el: HTMLElement | null = node instanceof HTMLElement ? node : (node?.parentElement ?? null); + while (el && el !== body) { + if (el.dataset.srcStart !== undefined && el.dataset.srcEnd !== undefined) return el; + el = el.parentElement; + } + return null; +} + +/** The source [start,end) union of the live blocks a non-empty body selection touches, or null. */ +function selectionSrcRange(): { start: number; end: number } | null { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null; + const ends = [nearestSrc(sel.anchorNode), nearestSrc(sel.focusNode)].filter( + (e): e is HTMLElement => e !== null, + ); + if (ends.length === 0) return null; // selection touches no live-source block + const starts = ends.map((e) => Number(e.dataset.srcStart)); + const stops = ends.map((e) => Number(e.dataset.srcEnd)); + return { start: Math.min(...starts), end: Math.max(...stops) }; +} + +function updateAskLabel(): void { + if (!askEl) return; + askEl.textContent = selectionSrcRange() + ? "✦ Ask Claude to Edit Selection" + : "✦ Ask Claude to Edit Document"; +} + +document.addEventListener("selectionchange", updateAskLabel); + askEl?.addEventListener("click", () => { - vscodeApi.postMessage({ type: "askClaude", scope: "document" }); + const range = selectionSrcRange(); + if (range) { + vscodeApi.postMessage({ type: "askClaude", scope: "selection", start: range.start, end: range.end }); + } else { + vscodeApi.postMessage({ type: "askClaude", scope: "document" }); + } }); // F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam). @@ -83,6 +123,7 @@ window.addEventListener("message", (event: MessageEvent) => { const msg = event.data; if (msg?.type !== "render") return; body.innerHTML = msg.html; + updateAskLabel(); // new content clears any selection → reset the adaptive label const on = msg.mode === "on"; if (annotationsEl) annotationsEl.checked = on; // Off-state is a clean preview: hide the review chrome. diff --git a/test/e2e/suite/f11Toolbar.test.ts b/test/e2e/suite/f11Toolbar.test.ts index 573bc15..d06cec2 100644 --- a/test/e2e/suite/f11Toolbar.test.ts +++ b/test/e2e/suite/f11Toolbar.test.ts @@ -101,6 +101,49 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () = void key; }); + // SLICE-4: Edit Selection → one single-range proposal over the resolved block-union. + test("runEditAndPropose(range) with a stubbed turn → exactly one proposal over the resolved range (PUC-3, INV-37)", async () => { + const body = "# F11 sel\n\nThe target paragraph Claude will rewrite.\n\nAnother untouched paragraph.\n"; + const { doc, key } = await freshDoc("docs/f11sel.md", body); + const api = await getApi(); + const ctl = api.trackChangesPreviewController; + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + + const target = "The target paragraph Claude will rewrite."; + const start = doc.getText().indexOf(target); + const end = start + target.length; + ctl.setEditTurnForTest(async (_instruction, text) => { + assert.strictEqual(text, target, "the turn receives exactly the selected source range"); + return { replacement: "The REWRITTEN paragraph from Claude.", model: "sonnet", sessionId: "e2e-f11-sel" }; + }); + + const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph"); + await settle(); + assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal"); + const views = api.proposalController.listProposals(doc); + const view = views.find((v) => v.id === ids[0]); + assert.ok(view, "the proposal is live"); + assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement"); + assert.strictEqual(view!.replaced, target, "replaces exactly the selected range"); + assert.ok(doc.getText().includes(target), "document unchanged by propose (INV-10)"); + void key; + }); + + // SLICE-4: a no-op turn (Claude returns the input unchanged) produces no proposal. + test("runEditAndPropose(range) where Claude returns the selection unchanged → no proposal", async () => { + const { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n"); + const api = await getApi(); + const ctl = api.trackChangesPreviewController; + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + const target = "Leave me exactly as I am."; + const start = doc.getText().indexOf(target); + ctl.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" })); + const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change"); + assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing"); + }); + // SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown. test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => { const all = await vscode.commands.getCommands(true);