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);