From 8b9e61a1da9cec8cd89e93e5d9c0d3a626d5d622 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 12 Jun 2026 13:40:21 -0700 Subject: [PATCH 1/6] =?UTF-8?q?feat(f11):=20SLICE-1=20=E2=80=94=20Pin=20ba?= =?UTF-8?q?seline=20toolbar=20button=20+=20reachability=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Homes the orphaned cowriting.pinDiffBaseline command and gives the writer a reachable Pin control in the preview toolbar. Per spec §7.2 SLICE-1 (docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md). - trackChangesPreview: extract onDidReceiveMessage into handleWebviewMessage; add the F11 `pinBaseline` intent → DiffViewController.pin(previewedDoc) (the bound doc, not activeTextEditor — §6.7); ToolbarMsg union; receiveMessage test seam exercising the real message→seam wiring (INV-35). - webview: ⌖ Pin baseline button in #cw-header posting { type: "pinBaseline" }; theme-aware toolbar-button CSS (light/dark/high-contrast, disabled state). - package.json: unhide pinDiffBaseline — commandPalette `when` false → editorLangId == markdown (resolves the #34 orphan from the command side). - host E2E (test/e2e/suite/f11Toolbar.test.ts): pinBaseline message clears the change-marks + advances the baseline to `pinned`; palette `when` is reachable. Also archives the F11 implementation plan to docs/superpowers/plans/. 197 unit + 47 host E2E green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-12-f11-preview-toolbar.md | 123 ++++++++++++++++++ media/preview.css | 13 ++ media/preview.ts | 6 + package.json | 2 +- src/trackChangesPreview.ts | 62 +++++++-- test/e2e/suite/f11Toolbar.test.ts | 69 ++++++++++ 6 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-12-f11-preview-toolbar.md create mode 100644 test/e2e/suite/f11Toolbar.test.ts diff --git a/docs/superpowers/plans/2026-06-12-f11-preview-toolbar.md b/docs/superpowers/plans/2026-06-12-f11-preview-toolbar.md new file mode 100644 index 0000000..ca7c86f --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-f11-preview-toolbar.md @@ -0,0 +1,123 @@ +# Implementation Plan: F11 — Preview Toolbar as the Primary Interaction Surface (#43) + +**Spec:** `docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md` +**Anchor:** Feature `benstull/vscode-cowriting-plugin#43` (F11, `type/feature`, `priority/P1`) +**Session:** vscode-cowriting-plugin-0037 + +This plan transcribes the spec's §7.2 slicing plan into concrete, file-level +tasks. Each slice is independently green (unit + host E2E) before the next. Host +E2E is this app's required tier (no browser/deploy stage — a VS Code extension); +no LLM in CI (edit turns stubbed). The webview's visual rendering, the adaptive +label, and the selection→source DOM lookup are manual-smoke only. + +--- + +## SLICE-1 — Pin baseline button + reachability *(the immediate win)* + +Homes the orphaned `cowriting.pinDiffBaseline` command and gives the writer a +reachable Pin control in the preview toolbar. + +**Tasks** + +1. **Host message routing** (`src/trackChangesPreview.ts`): extract the inline + `onDidReceiveMessage` body into a private `handleWebviewMessage(document, m)` + method; add a `pinBaseline` branch that calls `this.diffView.pin(document)` + (the *previewed* document — not `activeTextEditor`). The existing + `onDidChangeBaseline` subscription already re-renders with cleared marks. +2. **Test seam**: add `receiveMessage(uriString, m)` that resolves the doc and + calls `handleWebviewMessage`, so host E2E can simulate the raw webview message + and exercise the real routing. +3. **Webview** (`media/preview.ts` + `.css`): add a `⌖ Pin baseline` button in + `#cw-header`; click → `postMessage({ type: "pinBaseline" })`; theme-aware CSS. +4. **Shell HTML** (`shellHtml`): add the ` Review @@ -246,6 +271,15 @@ export class TrackChangesPreviewController implements vscode.Disposable { isOpen(uriString: string): boolean { return this.panels.has(uriString); } + /** + * F11 test seam: deliver an inbound webview message to the real routing, as if + * the sealed webview had posted it. Exercises message→seam wiring without a + * live webview DOM (which is manual-smoke only). No-op if no doc/panel. + */ + receiveMessage(uriString: string, m: ToolbarMsg): void { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); + if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m); + } getLastModel(uriString: string): BlockOp[] | undefined { return this.lastModel.get(uriString); } diff --git a/test/e2e/suite/f11Toolbar.test.ts b/test/e2e/suite/f11Toolbar.test.ts new file mode 100644 index 0000000..cc07ea2 --- /dev/null +++ b/test/e2e/suite/f11Toolbar.test.ts @@ -0,0 +1,69 @@ +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 && api?.diffViewController, "exports preview + diffView"); + return api; +} + +/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */ +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); + await vscode.window.showTextDocument(doc); + await settle(); + return { doc, key: uri.toString() }; +} + +// F11 host E2E (no LLM): the preview toolbar is the primary interaction surface. +// The webview posts intent messages; the host routes them through the existing +// F4/F6/F3 seams (INV-35). The webview DOM (real button clicks) is sealed and +// manual-smoke only; here we simulate the inbound messages via `receiveMessage`. +suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () => { + // SLICE-1: the Pin baseline button. + test("pinBaseline message pins the PREVIEWED doc → marks clear, baseline reason is pinned (PUC-5, INV-35)", async () => { + const { doc, key } = await freshDoc("docs/f11pin.md", "# F11 pin\n\nA baseline paragraph that will diverge.\n"); + const api = await getApi(); + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open"); + + // Diverge from the opened baseline so the preview carries a real change-mark. + const edit = new vscode.WorkspaceEdit(); + edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed paragraph that diverges.\n"); + assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied"); + await settle(); + const marked = (api.trackChangesPreviewController.getLastModel(key) ?? []).some((o) => o.kind !== "unchanged"); + assert.ok(marked, "the typed paragraph shows as a change before pinning"); + + // Simulate the webview's Pin baseline button posting its intent. + api.trackChangesPreviewController.receiveMessage(key, { type: "pinBaseline" }); + await settle(); + + const model = api.trackChangesPreviewController.getLastModel(key) ?? []; + assert.ok(model.length > 0 && model.every((o) => o.kind === "unchanged"), "after pin, every block is unchanged"); + assert.strictEqual(api.diffViewController.getBaseline(key)?.reason, "pinned", "baseline reason advanced to pinned"); + }); + + // SLICE-1 reachability: the orphaned pin command gets a real palette `when`. + test("pinDiffBaseline is reachable from the command palette (when: editorLangId == markdown)", async () => { + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); + const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find( + (m) => m.command === "cowriting.pinDiffBaseline", + ); + assert.ok(entry, "pinDiffBaseline has a commandPalette entry"); + assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)"); + assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); + }); +}); -- 2.39.5 From 1ef9451e890d34a1b9a3cdb653fd48d20eedf9d3 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 12 Jun 2026 13:45:02 -0700 Subject: [PATCH 2/6] =?UTF-8?q?feat(f11):=20SLICE-2=20=E2=80=94=20block-le?= =?UTF-8?q?vel=20data-src=20emission=20(#43,=20INV-36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure render layer now emits data-src-start/data-src-end (source char offsets from BlockWithRange) on every LIVE-source rendered block, in BOTH modes — the contract the webview's selection→source mapping (SLICE-4) walks the DOM for. Per spec §6.1 INV-36 / §7.2 SLICE-2. - trackChangesModel: shared `srcAttr(blk)` helper; threaded through renderOp + renderReviewOp + the renderReview loop (removed blocks → "" / no data-src, as they have no live source). renderPlain switches from a single whole-document markdown pass to per-block `
` wrappers — bare divs (no cw- class) so the off/clean preview stays visually clean while becoming a selection→source surface. Pure, vscode-free, deterministic (extends INV-22). - unit (test/trackChangesModel.test.ts): data-src offsets equal splitBlocksWithRanges in both modes; removed + proposal blocks carry none; off-mode stays cw--free; determinism. 200 unit + 47 host E2E green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/trackChangesModel.ts | 45 +++++++++++++++++++++++-------- test/trackChangesModel.test.ts | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 13989d9..bb44737 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -247,7 +247,17 @@ function chip(message: string): string { return `
Could not render this block: ${md.utils.escapeHtml(message)}
`; } -function renderOp(op: BlockOp, render: (src: string) => string): string { +/** + * F11 (INV-36): the source-range attributes a live block's wrapping element + * carries, so a preview selection can map back to a source markdown range. `""` + * when the block has no live source (a baseline-only deletion / a proposal + * block) — those are skipped by the selection mapper. Pure + deterministic. + */ +function srcAttr(blk: { start: number; end: number } | undefined): string { + return blk ? ` data-src-start="${blk.start}" data-src-end="${blk.end}"` : ""; +} + +function renderOp(op: BlockOp, render: (src: string) => string, src = ""): string { const safe = (src: string): string => { try { return render(src); @@ -293,7 +303,7 @@ function renderOp(op: BlockOp, render: (src: string) => string): string { } break; } - return `
${badge}${inner}
`; + return `
${badge}${inner}
`; } export type AuthorKind = "claude" | "human"; @@ -367,14 +377,25 @@ export function colorByAuthor( return sentinelsToSpans(render(injected)); } -/** Off-state body: the current buffer as plain markdown, no annotations (INV-33). */ +/** + * Off-state body: the current buffer as plain markdown, no annotations (INV-33). + * Each block is wrapped in a bare `
` so the off-mode + * preview is still a selection→source mapping surface (INV-36) while staying + * visually clean — no `cw-` annotation classes. A doc with no blocks (empty / + * whitespace-only) renders whole. Pure + deterministic. + */ export function renderPlain(currentText: string, opts: RenderOptions = {}): string { const render = opts.render ?? defaultRender; - try { - return render(currentText); - } catch (err) { - return chip(err instanceof Error ? err.message : String(err)); - } + const safe = (src: string): string => { + try { + return render(src); + } catch (err) { + return chip(err instanceof Error ? err.message : String(err)); + } + }; + const blocks = splitBlocksWithRanges(currentText); + if (blocks.length === 0) return safe(currentText); + return blocks.map((b) => `${safe(b.raw)}
`).join("\n"); } export interface ProposalView { @@ -411,11 +432,13 @@ function renderReviewOp( op: BlockOp, render: (src: string) => string, colored: (raw: string) => string, + src: string, ): string { // removed blocks and any changed block (atomic fences diffed whole; non-atomic prose // word-merged) render via renderOp — no author sentinels (deletions neutral, spec §6.7). - if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render); - return `
${colored(op.block.raw)}
`; + // `src` is "" for a removed block (no live source — INV-36). + if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src); + return `
${colored(op.block.raw)}
`; } /** @@ -471,7 +494,7 @@ export function renderReview( const blk = op.kind === "removed" ? undefined : ranges[ci++]; const colored = (raw: string): string => blk ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw); - bodyParts.push(renderReviewOp(op, render, colored)); + bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk))); const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined; if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render)); } diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 72c06e7..e995f56 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -319,3 +319,51 @@ describe("renderTrackChanges — intra-diagram mermaid (#22)", () => { expect(html).not.toContain("classDef cwAdded"); }); }); + +// F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end +// (source char offsets from BlockWithRange) on every LIVE-source rendered block, +// in BOTH modes. The webview's selection→source mapping walks the DOM to the +// nearest data-src ancestor; these offsets are the contract. +describe("F11 data-src emission (INV-36)", () => { + /** Pull every data-src-start/end pair from an HTML string, in document order. */ + const srcRanges = (html: string): Array<{ start: number; end: number }> => + Array.from(html.matchAll(/data-src-start="(\d+)" data-src-end="(\d+)"/g)).map((m) => ({ + start: Number(m[1]), + end: Number(m[2]), + })); + + test("renderPlain wraps every block with data-src offsets equal to splitBlocksWithRanges (and stays clean)", () => { + const doc = "# Title\n\nFirst para.\n\nSecond para.\n"; + const blocks = splitBlocksWithRanges(doc); + const html = renderPlain(doc); + expect(srcRanges(html)).toEqual(blocks.map((b) => ({ start: b.start, end: b.end }))); + // off-mode stays the clean preview: no annotation marks. + expect(html).not.toContain("cw-"); + // each range slices back to its block's raw source. + for (const b of blocks) expect(doc.slice(b.start, b.end)).toBe(b.raw); + }); + + test("renderReview emits data-src on each live block; removed + proposal blocks carry none (INV-36)", () => { + // baseline has an extra paragraph that is REMOVED in current; current adds one. + const baseline = "Keep this.\n\nDrop this.\n"; + const current = "Keep this.\n\nBrand new.\n"; + const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 4, replaced: "Keep", replacement: "Hold" }]; + const html = renderReview(baseline, current, [], proposals); + const liveBlocks = splitBlocksWithRanges(current); + // exactly one data-src per LIVE (current-side) block — removed/proposal blocks excluded. + expect(srcRanges(html)).toEqual(liveBlocks.map((b) => ({ start: b.start, end: b.end }))); + // the proposal block itself is not a live-source block. + const propIdx = html.indexOf('data-proposal-id="p1"'); + const propTag = html.slice(html.lastIndexOf(" { + const a = renderPlain("alpha\n\nbeta\n"); + const b = renderPlain("alpha\n\nbeta\n"); + expect(a).toBe(b); + const r1 = renderReview("x", "x\n\ny", [], []); + const r2 = renderReview("x", "x\n\ny", [], []); + expect(r1).toBe(r2); + }); +}); -- 2.39.5 From 0d1a5635cbd415183598c3e133d96de46a12173b Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 12 Jun 2026 13:51:07 -0700 Subject: [PATCH 3/6] =?UTF-8?q?feat(f11):=20SLICE-3=20=E2=80=94=20Edit=20D?= =?UTF-8?q?ocument=20button=20+=20per-hunk=20proposal=20path=20(#43,=20INV?= =?UTF-8?q?-37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A whole-document Ask-Claude rewrite is diffed into hunks and surfaced as N independent F4 proposals (one per changed hunk) — reusing the F4 single-range model N times, no new model. Per spec §6.4/§7.2 SLICE-3. - trackChangesModel: pure `diffToHunks(currentText, rewrittenText)` → EditHunk[] (vscode-free, deterministic; diffWordsWithSpace, coalescing adjacent add/remove runs; offsets index currentText). - trackChangesPreview: `runEditAndPropose(document, target, instruction)` — the shared host routine (selection → one single-range propose; document → diff → one propose per hunk; never mutates the doc, INV-10); `askClaude` UI wrapper (host showInputBox keeps LLM/secrets out of the sealed webview, INV-8/35); injectable `editTurn` + `setEditTurnForTest` seam (no LLM in CI); the `askClaude` inbound message branch; `cowriting.editDocument` command for #42 reuse. - package.json: register cowriting.editDocument, palette-guarded on markdown. - webview: ✦ Ask Claude to Edit Document button → { askClaude, scope:"document" }. - unit: diffToHunks fixtures (zero/one/multi-hunk, wholesale, determinism). - host E2E: stubbed multi-hunk rewrite → N matching proposals, doc untouched; editDocument command registered + markdown-guarded. 205 unit + 49 host E2E green. Co-Authored-By: Claude Opus 4.8 (1M context) --- media/preview.ts | 8 +++ package.json | 9 +++ src/trackChangesModel.ts | 46 ++++++++++++- src/trackChangesPreview.ts | 105 +++++++++++++++++++++++++++++- test/e2e/suite/f11Toolbar.test.ts | 46 +++++++++++++ test/trackChangesModel.test.ts | 44 ++++++++++++- 6 files changed, 254 insertions(+), 4 deletions(-) diff --git a/media/preview.ts b/media/preview.ts index 4a3ad35..ee636b3 100644 --- a/media/preview.ts +++ b/media/preview.ts @@ -27,6 +27,7 @@ const summary = document.getElementById("cw-summary")!; const legend = document.getElementById("cw-legend")!; const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null; const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null; +const askEl = document.getElementById("cw-ask") as HTMLButtonElement | null; // F10: the annotations on/off toggle. annotationsEl?.addEventListener("change", () => { @@ -38,6 +39,13 @@ 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). +askEl?.addEventListener("click", () => { + vscodeApi.postMessage({ type: "askClaude", scope: "document" }); +}); + // F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam). body.addEventListener("click", (e) => { const btn = (e.target as HTMLElement)?.closest(".cw-actions button"); diff --git a/package.json b/package.json index 716d3fa..2a4f50f 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,11 @@ "command": "cowriting.showTrackChangesPreview", "title": "Cowriting: Open Review Preview", "category": "Cowriting" + }, + { + "command": "cowriting.editDocument", + "title": "Ask Claude to Edit Document", + "category": "Cowriting" } ], "menus": { @@ -101,6 +106,10 @@ { "command": "cowriting.pinDiffBaseline", "when": "editorLangId == markdown" + }, + { + "command": "cowriting.editDocument", + "when": "editorLangId == markdown" } ], "editor/context": [ diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index bb44737..3a5a6ce 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -9,7 +9,7 @@ * DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview. */ import MarkdownIt from "markdown-it"; -import { diffArrays, diffWords } from "diff"; +import { diffArrays, diffWords, diffWordsWithSpace } from "diff"; import { diffMermaid } from "./mermaidDiff"; export type BlockType = "prose" | "code" | "mermaid"; @@ -193,6 +193,50 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[] return ops; } +/** A contiguous changed region of `currentText` and its replacement (F11). */ +export interface EditHunk { + /** char offset of the hunk's first changed char in currentText. */ + start: number; + /** char offset one past the hunk's last changed char (start==end → a pure insertion). */ + end: number; + /** the text that replaces currentText.slice(start, end). */ + replacement: string; +} + +/** + * F11 (INV-37, §6.4): diff a whole-document rewrite into per-hunk replacement + * ranges — each becomes one independent F4 single-range proposal (so a document + * edit surfaces as N independently ✓/✗-able blue blocks, no new model). Pure, + * vscode-free, deterministic. Offsets index into `currentText`: removed + + * unchanged parts reconstruct it exactly, so advancing on those yields true + * source offsets. Adjacent added/removed runs coalesce into one hunk; an + * unchanged part flushes the open hunk. + */ +export function diffToHunks(currentText: string, rewrittenText: string): EditHunk[] { + const hunks: EditHunk[] = []; + let offset = 0; + let open: EditHunk | null = null; + const flush = () => { + if (open) hunks.push(open); + open = null; + }; + for (const part of diffWordsWithSpace(currentText, rewrittenText)) { + if (part.added) { + open ??= { start: offset, end: offset, replacement: "" }; + open.replacement += part.value; + } else if (part.removed) { + open ??= { start: offset, end: offset, replacement: "" }; + offset += part.value.length; + open.end = offset; + } else { + flush(); + offset += part.value.length; + } + } + flush(); + return hunks; +} + const md = new MarkdownIt({ html: true, linkify: false, breaks: false }); // mermaid fences →
SRC
for client-side rendering; all // other fences fall through to markdown-it's default (escaped
).
diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts
index 43f7be1..20112c8 100644
--- a/src/trackChangesPreview.ts
+++ b/src/trackChangesPreview.ts
@@ -13,7 +13,14 @@ import * as vscode from "vscode";
 import type { DiffViewController } from "./diffViewController";
 import type { AttributionController } from "./attributionController";
 import type { ProposalController } from "./proposalController";
-import { renderReview, renderPlain, diffBlocks, type BlockOp } from "./trackChangesModel";
+import { renderReview, renderPlain, diffBlocks, diffToHunks, type BlockOp } from "./trackChangesModel";
+import { buildFingerprint } from "./anchorer";
+import type { EditTurnResult } from "./liveTurn";
+
+/** F11: a host edit turn (selection/document text + instruction → rewrite). Injectable for tests. */
+type EditTurn = (instruction: string, text: string) => Promise;
+/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
+type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
 
 const VIEW_TYPE = "cowriting.trackChangesPreview";
 const DEBOUNCE_MS = 150;
@@ -27,7 +34,9 @@ type ToolbarMsg =
   | { type: "setMode"; mode: "on" | "off" }
   | { type: "accept"; proposalId: string }
   | { type: "reject"; proposalId: string }
-  | { type: "pinBaseline" };
+  | { type: "pinBaseline" }
+  | { type: "askClaude"; scope: "document" }
+  | { type: "askClaude"; scope: "selection"; start: number; end: number };
 
 export class TrackChangesPreviewController implements vscode.Disposable {
   private readonly disposables: vscode.Disposable[] = [];
@@ -38,6 +47,14 @@ export class TrackChangesPreviewController implements vscode.Disposable {
   private readonly mode = new Map();
   /** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */
   private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88);
+  /**
+   * F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
+   * never bundled). Injectable so host E2E can stub it (no LLM in CI).
+   */
+  private editTurn: EditTurn = async (instruction, text) => {
+    const { runEditTurn } = await import("./liveTurn");
+    return runEditTurn(instruction, text);
+  };
 
   constructor(
     private readonly diffView: DiffViewController,
@@ -49,6 +66,16 @@ export class TrackChangesPreviewController implements vscode.Disposable {
       vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
         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;
+        if (!doc || !this.isMarkdown(doc)) {
+          void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
+          return;
+        }
+        void this.askClaude(doc, { kind: "document" });
+      }),
       vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
       this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
       this.proposals.onDidChangeProposals(({ uri }) => {
@@ -136,9 +163,78 @@ export class TrackChangesPreviewController implements vscode.Disposable {
     } else if (m?.type === "pinBaseline") {
       // F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
       this.diffView.pin(document);
+    } else if (m?.type === "askClaude") {
+      const target: EditTarget =
+        m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
+      void this.askClaude(document, target);
     }
   }
 
+  /**
+   * F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
+   * surface out of the sealed webview, INV-8/35), run the edit turn, and surface
+   * the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
+   */
+  private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise {
+    const instruction = await vscode.window.showInputBox({
+      prompt:
+        target.kind === "document"
+          ? "What should Claude do with the document?"
+          : "What should Claude do with the selection?",
+      placeHolder: "e.g. tighten the prose",
+    });
+    if (!instruction) return;
+    try {
+      const ids = await vscode.window.withProgress(
+        { location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
+        () => this.runEditAndPropose(document, target, instruction),
+      );
+      if (ids.length === 0) {
+        void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
+      } else {
+        void vscode.window.showInformationMessage(
+          `Cowriting: Claude proposed ${ids.length} edit${ids.length === 1 ? "" : "s"} — review ✓/✗ in the preview.`,
+        );
+      }
+    } catch (err) {
+      const message = err instanceof Error ? err.message : String(err);
+      void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
+    }
+  }
+
+  /**
+   * F11 (INV-35/37): run one host edit turn and record the result as F4
+   * proposal(s) — a SELECTION yields one single-range proposal over the resolved
+   * block-union; a DOCUMENT rewrite is `diffToHunks`'d into one single-range
+   * proposal per changed hunk (reusing the F4 single-range model N times, no new
+   * model). Never mutates the document (INV-10). Returns the created proposal ids.
+   */
+  async runEditAndPropose(
+    document: vscode.TextDocument,
+    target: EditTarget,
+    instruction: string,
+  ): Promise {
+    const full = document.getText();
+    const provenance = (turn: EditTurnResult) =>
+      ({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
+    if (target.kind === "range") {
+      const selected = full.slice(target.start, target.end);
+      const turn = await this.editTurn(instruction, selected);
+      if (turn.replacement === "" || turn.replacement === selected) return [];
+      const fp = buildFingerprint(full, { start: target.start, end: target.end });
+      const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { instruction });
+      return id ? [id] : [];
+    }
+    const turn = await this.editTurn(instruction, full);
+    const ids: string[] = [];
+    for (const h of diffToHunks(full, turn.replacement)) {
+      const fp = buildFingerprint(full, { start: h.start, end: h.end });
+      const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), { instruction });
+      if (id) ids.push(id);
+    }
+    return ids;
+  }
+
   private onEdit(document: vscode.TextDocument): void {
     const key = document.uri.toString();
     if (!this.panels.has(key)) return;
@@ -257,6 +353,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
   
+ Review @@ -280,6 +377,10 @@ export class TrackChangesPreviewController implements vscode.Disposable { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m); } + /** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */ + setEditTurnForTest(fn: EditTurn): void { + this.editTurn = fn; + } getLastModel(uriString: string): BlockOp[] | undefined { return this.lastModel.get(uriString); } diff --git a/test/e2e/suite/f11Toolbar.test.ts b/test/e2e/suite/f11Toolbar.test.ts index cc07ea2..573bc15 100644 --- a/test/e2e/suite/f11Toolbar.test.ts +++ b/test/e2e/suite/f11Toolbar.test.ts @@ -66,4 +66,50 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () = assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)"); assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); }); + + // SLICE-3: Edit Document → a whole-document rewrite diffed into N F4 proposals. + test("runEditAndPropose(document) with a stubbed multi-hunk rewrite → N proposals matching the hunks (PUC-4, INV-37)", async () => { + const { doc, key } = await freshDoc( + "docs/f11doc.md", + "# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n", + ); + const api = await getApi(); + const ctl = api.trackChangesPreviewController; + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + + // Stub the host edit turn (no LLM in CI): rewrite two distinct words. + ctl.setEditTurnForTest(async () => ({ + replacement: "# F11 doc\n\nThe quick RED fox jumps over the lazy CAT.\n", + model: "sonnet", + sessionId: "e2e-f11-doc", + })); + + const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT"); + await settle(); + assert.strictEqual(ids.length, 2, "two changed words → two independent proposals"); + + const views = api.proposalController.listProposals(doc); + assert.ok( + ids.every((id) => views.some((v) => v.id === id)), + "every returned proposal id is a live pending proposal", + ); + const replacements = views.map((v) => v.replacement); + assert.ok(replacements.includes("RED") && replacements.includes("CAT"), "proposals carry the per-hunk replacements"); + // INV-10: proposing never mutates the document. + assert.ok(doc.getText().includes("brown fox") && doc.getText().includes("lazy dog"), "document unchanged by propose"); + void key; + }); + + // 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); + assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered"); + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); + const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find( + (m) => m.command === "cowriting.editDocument", + ); + assert.ok(entry, "editDocument has a commandPalette entry"); + assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); + }); }); diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index e995f56..7389fa6 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -1,5 +1,5 @@ import { describe, it, test, expect } from "vitest"; -import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel"; +import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel"; describe("splitBlocks", () => { it("splits prose paragraphs on blank lines, dropping empties", () => { @@ -320,6 +320,48 @@ describe("renderTrackChanges — intra-diagram mermaid (#22)", () => { }); }); +// F11 SLICE-3 (INV-37, §6.4): a whole-document rewrite is diffed into per-hunk +// proposal ranges — each an independent F4 single-range proposal. Pure, +// vscode-free, deterministic; offsets index into currentText. +describe("F11 diffToHunks (INV-37)", () => { + test("an identical rewrite → zero hunks", () => { + expect(diffToHunks("The same text.\n", "The same text.\n")).toEqual([]); + }); + + test("a single changed word → one hunk over exactly that word", () => { + const current = "The quick brown fox."; + const hunks = diffToHunks(current, "The quick red fox."); + expect(hunks).toHaveLength(1); + expect(current.slice(hunks[0].start, hunks[0].end)).toBe("brown"); + expect(hunks[0].replacement).toBe("red"); + }); + + test("two disjoint changes → two hunks with correct ranges + replacements", () => { + const current = "one two three four"; + const hunks = diffToHunks(current, "one TWO three FOUR"); + expect(hunks).toHaveLength(2); + expect(current.slice(hunks[0].start, hunks[0].end)).toBe("two"); + expect(hunks[0].replacement).toBe("TWO"); + expect(current.slice(hunks[1].start, hunks[1].end)).toBe("four"); + expect(hunks[1].replacement).toBe("FOUR"); + // disjoint + ordered + expect(hunks[0].end).toBeLessThanOrEqual(hunks[1].start); + }); + + test("a wholesale replacement (nothing in common) → one full-range hunk", () => { + const current = "alpha"; + const hunks = diffToHunks(current, "omega"); + expect(hunks).toHaveLength(1); + expect(hunks[0]).toEqual({ start: 0, end: current.length, replacement: "omega" }); + }); + + test("is deterministic — same inputs → identical hunks", () => { + const a = diffToHunks("a b c d", "a B c D"); + const b = diffToHunks("a b c d", "a B c D"); + expect(a).toEqual(b); + }); +}); + // F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end // (source char offsets from BlockWithRange) on every LIVE-source rendered block, // in BOTH modes. The webview's selection→source mapping walks the DOM to the -- 2.39.5 From 03b61ed43ebf3dcb2413bcb8f9cc8ea88125b49b Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 12 Jun 2026 13:53:14 -0700 Subject: [PATCH 4/6] =?UTF-8?q?feat(f11):=20SLICE-4=20=E2=80=94=20single?= =?UTF-8?q?=20adaptive=20Ask-Claude=20button=20+=20selection=20mapping=20(?= =?UTF-8?q?#43,=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); -- 2.39.5 From 1564ef562b0cddc1d6a8899f32a82dc59b09f785 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 12 Jun 2026 13:57:28 -0700 Subject: [PATCH 5/6] =?UTF-8?q?feat(f11):=20SLICE-5=20=E2=80=94=20gateway,?= =?UTF-8?q?=20non-authorable=20disabling,=20docs=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the F11 feature: makes the toolbar surface reachable end to end and guards the edit controls. Per spec §6.4/§6.5/§7.2 SLICE-5. - package.json: cowriting.showTrackChangesPreview added to editor/title (when: editorLangId == markdown) — the minimal right-click → Open Review Preview gateway (#41/#42 expand it later). - trackChangesPreview: the gateway command accepts the tab's resource Uri (palette/keybinding still fall back to the active editor); refresh() sends an `authorable` flag on both render messages; `editControlsEnabled` test seam. - webview: disable Pin + Ask-Claude on a non-authorable doc (Annotations stays active — reading is always allowed); RenderMessage.authorable. - host E2E: the editor/title gateway opens the preview + is markdown-guarded; edit controls disabled on a non-authorable (read-only-scheme) markdown doc. - docs: docs/MANUAL-SMOKE-F11.md (live smoke, 10 steps) + README F11 section + intro line. 205 unit + 53 host E2E green. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 32 ++++++++++++++- docs/MANUAL-SMOKE-F11.md | 67 +++++++++++++++++++++++++++++++ media/preview.ts | 6 +++ package.json | 7 ++++ src/trackChangesPreview.ts | 24 +++++++++-- test/e2e/suite/f11Toolbar.test.ts | 48 ++++++++++++++++++++++ 6 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 docs/MANUAL-SMOKE-F11.md diff --git a/README.md b/README.md index 405e96f..73cccb5 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ catalog (a pure, key-free SDK call) in a notification and the Features shipped so far: F2 region-anchored threads (Feature #4), F3 live human/Claude attribution (Feature #6), F4 propose/accept diff flow (Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view -toggle (Feature #17), and F10 interactive review — **write left / review -right** (Feature #29). +toggle (Feature #17), F10 interactive review — **write left / review +right** (Feature #29), and F11 — the **preview toolbar as the primary +interaction surface** (Feature #43). ## Architecture @@ -262,6 +263,33 @@ they are no longer separate user surfaces. Design: `vscode-cowriting-plugin-content/specs/coauthoring-interactive-review.md`. Live smoke: [`docs/MANUAL-SMOKE-F10.md`](docs/MANUAL-SMOKE-F10.md). +## F11 — Preview toolbar as the primary interaction surface (Feature #43) + +The review preview's **header toolbar** becomes the cockpit for the inner loop. +Beside the existing **Annotations** switch it gains two controls: + +- **⌖ Pin baseline** — pins the previewed document's review baseline to now and + clears the change-marks (homes the previously-orphaned `pinDiffBaseline` + command, which is reachable from the palette again too). +- **✦ Ask Claude…** — one **adaptive** button. Its label flips on the preview's + selection: **Edit Selection** when text is selected in the rendered preview, + **Edit Document** otherwise. Clicking it opens a host input box for the + instruction (the LLM turn and prompt stay host-side — the sealed webview gains + no LLM/credential surface), then surfaces the result as F4 proposals — **one** + for a selection (mapped back to its source block-union), or **one per changed + hunk** for a whole-document rewrite (`diffToHunks`), each independently ✓/✗-able. + +The pure render layer emits `data-src-start`/`data-src-end` on every block in +**both** modes (INV-36); the webview's only mapping duty is walking a selection +to its nearest `data-src` ancestor. Right-clicking a markdown tab → **Open Review +Preview** is the minimal gateway making the surface reachable end to end (#41/#42 +expand it). Edit controls are disabled on a non-authorable doc (reading stays +allowed). No new model, no new persistence — pin via the F6 store, edits via the +F4 propose/accept seam with F3 attribution (INV-35..37). + +Design: [`docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md`](docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md). +Live smoke: [`docs/MANUAL-SMOKE-F11.md`](docs/MANUAL-SMOKE-F11.md). + ## Develop - `npm run watch` — rebuild on change. diff --git a/docs/MANUAL-SMOKE-F11.md b/docs/MANUAL-SMOKE-F11.md new file mode 100644 index 0000000..3ccdae1 --- /dev/null +++ b/docs/MANUAL-SMOKE-F11.md @@ -0,0 +1,67 @@ +# Manual smoke — F11 preview toolbar as the primary interaction surface (#43) + +F11 makes the rendered preview's **header toolbar** the primary interaction +surface: beside the existing **Annotations** switch it gains a **Pin baseline** +button and a **single adaptive Ask-Claude button** (Edit Selection when text is +selected in the preview, Edit Document otherwise). The webview's *visual* +behavior — the buttons, the live label flip, and the selection→source mapping — +is verified **here**, not in the automated host E2E (the webview is a sealed +sandbox; the host's message→seam wiring is covered by the F11 E2E suite). Run +once per change that touches F11. Live turns hit the SDK; use a trivial +instruction to keep them quick. + +## Setup + +1. `npm run build` +2. Launch the Extension Development Host (F5 in VS Code, or the Run panel) with + `sandbox/` open. +3. Open a markdown document with a few paragraphs (e.g. copy + `test/e2e/fixtures/workspace/docs/preview.md`). + +## Steps + +1. **Right-click gateway (PUC-6).** Right-click the markdown editor **tab** (or + use the editor title bar) → **"Cowriting: Open Review Preview"**. The preview + opens beside the editor. (The palette entry and `Ctrl+Alt+R` still work too.) +2. **Three header controls (PUC-1).** The header row shows, left to right: the + **☑ Annotations** checkbox (on), a **⌖ Pin baseline** button, and a **✦ Ask + Claude to Edit Document** button. They restyle with the theme. +3. **Adaptive label — selection (PUC-2).** Select a paragraph **in the preview** + (drag across rendered text). The Ask-Claude button label flips live to **"✦ + Ask Claude to Edit Selection"**. Click elsewhere to clear the selection → it + flips back to **"✦ Ask Claude to Edit Document"**. +4. **Edit Selection (PUC-3).** With a paragraph selected in the preview, click + **Ask Claude to Edit Selection** → a host input box appears → type an + instruction (e.g. "tighten this") → submit. A **blue `cw-proposal` block** + appears **inline at that paragraph** with **✓ / ✗** (the selection mapped back + to its source block; the edit ran host-side). The editor does **not** change + (INV-10). Accept with **✓**: the replacement lands; the block clears. +5. **Edit Document (PUC-4).** Clear the selection (button reads "Edit Document"). + Click it → instruct (e.g. "fix any typos and tighten") → submit. Claude's + whole-document rewrite surfaces as **one or more** independent blue proposal + blocks (one per changed hunk), each independently **✓ / ✗**-able. Accept some, + reject others — each behaves as an ordinary F10 proposal. +6. **Pin baseline (PUC-5).** Make (or accept) a few changes so the preview shows + change-marks and a non-zero summary. Click **⌖ Pin baseline**. Expect: the + change-marks **clear**, the summary resets to `+0 −0`, and the header epoch + reads **`pinned