feat(f11): SLICE-1 — Pin baseline toolbar button + reachability (#43)

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) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-12 13:40:21 -07:00
parent 5966907089
commit 8b9e61a1da
6 changed files with 260 additions and 15 deletions
@@ -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 `<button id="cw-pin">` to the header row.
5. **Reachability** (`package.json`): flip `cowriting.pinDiffBaseline`'s
`commandPalette` `when` from `false` to `editorLangId == markdown`.
**Host E2E** (`test/e2e/suite/trackChangesPreview.test.ts` or new f11 suite):
open a markdown fixture with a divergent baseline → some block marked; simulate
`{type:"pinBaseline"}` via `receiveMessage``getLastModel` shows every block
`unchanged` (marks cleared) and `getBaseline(key).reason === "pinned"`.
**Unit:** none new (pure layer untouched this slice).
---
## SLICE-2 — Block-offset emission *(INV-36 data layer)*
Shared pure helper wrapping each rendered block with `data-src-start`/`-end`
(source char offsets from `BlockWithRange`) in **both** `renderReview` and
`renderPlain`. vscode-free, DOM-free, deterministic (extends INV-22/33). No UI.
**Tasks**
1. `src/trackChangesModel.ts`: a shared internal helper that prepends
`data-src-start="N" data-src-end="M"` to each block's wrapping element, routed
from both render paths using the existing `splitBlocksWithRanges` offsets.
2. Skip blocks with no live-source range (deletion-only / proposal blocks).
**Unit** (`test/trackChangesModel.test.ts`): `data-src-start/end` present and
correct on every block for both modes (offsets equal `BlockWithRange` ranges);
determinism (same inputs → identical HTML).
---
## SLICE-3 — Edit Document button + hunk path *(INV-37 document half)*
**Tasks**
1. `src/trackChangesModel.ts`: pure `diffToHunks(currentText, rewrittenText):
Array<{ start; end; replacement }>` — vscode-free, deterministic.
2. `src/trackChangesPreview.ts`: `runEditAndPropose(document, target, instruction)`
private routine; `askClaude`/`document` branch → host `showInputBox` →
`runEditTurn` over full text → `diffToHunks` → one F4 `propose()` per hunk.
3. `package.json`: register `cowriting.editDocument` (document-scoped), routed
through `runEditAndPropose({kind:"document"})`; for `#42` reuse.
4. Webview: `✦ Ask Claude to Edit Document` button (no-selection state) →
`postMessage({ type: "askClaude", scope: "document" })`.
**Unit:** `diffToHunks` over fixtures (single hunk → one range; multi-hunk →
disjoint ranges + correct replacements; unchanged → zero hunks; whole-doc
replacement → one full-range hunk).
**Host E2E:** simulate `{askClaude, scope:"document"}` with a stubbed multi-hunk
rewrite → **N** proposals matching the hunks.
---
## SLICE-4 — Adaptive Edit Selection *(INV-37 selection half; INV-36 consumer)*
**Tasks**
1. Webview: `selectionchange` listener flips the Ask-Claude label (Edit Selection
⇆ Edit Document); selection→nearest-`data-src` ancestor resolution →
`postMessage({ type:"askClaude", scope:"selection", start, end })`.
2. Host: `askClaude`/`selection` branch → `runEditAndPropose({kind:"range",
start, end})` → one `runEditTurn` → one F4 `propose()` over the block-union.
3. Edge: selection resolving to no live block → fall back to document scope.
**Host E2E:** simulate `{askClaude, scope:"selection", start, end}` with a stubbed
edit turn → exactly **one** proposal over the resolved range, anchored inline.
---
## SLICE-5 — Gateway, edges, tests & docs
**Tasks**
1. `package.json`: add `cowriting.showTrackChangesPreview` to `editor/title` with
`when: editorLangId == markdown` (minimal right-click gateway).
2. Non-authorable disabling: Pin + Ask-Claude controls render disabled when
`!isAuthorable(document)`; annotations toggle stays active.
3. Host E2E: gateway command opens the panel; controls inert on non-authorable.
4. `docs/MANUAL-SMOKE-F11.md` (live smoke script per spec §6.8).
5. README F11 section.
---
## Done = #43 acceptance (spec §7.3)
Preview toolbar hosts annotations checkbox + Pin baseline + single adaptive
Ask-Claude (Edit Selection ⇆ Edit Document) routing through existing F4/F3/F6;
edits surface as proposals (one for a selection, per-hunk for a document
rewrite); a right-click entry opens the preview; the pin command is no longer
orphaned; unit + host E2E green; live smoke performed once.
+13
View File
@@ -59,6 +59,19 @@ pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
#cw-summary .cw-prop { opacity: 0.85; }
/* F11 — preview toolbar buttons (Pin baseline; adaptive Ask Claude). Theme-aware. */
#cw-header button {
cursor: pointer;
font: inherit;
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 3px;
padding: 0.1em 0.55em;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
#cw-header button:hover:not(:disabled) { background: var(--vscode-button-secondaryHoverBackground); }
#cw-header button:disabled { opacity: 0.5; cursor: default; }
/* F10 interactive review — annotations toggle + ✓/✗ proposal blocks. */
#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; }
.cw-proposal {
+6
View File
@@ -26,12 +26,18 @@ const header = document.getElementById("cw-epoch")!;
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;
// F10: the annotations on/off toggle.
annotationsEl?.addEventListener("change", () => {
vscodeApi.postMessage({ type: "setMode", mode: annotationsEl.checked ? "on" : "off" });
});
// F11 (SLICE-1): Pin baseline — post intent; the host pins via the F6 store (INV-35).
pinEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "pinBaseline" });
});
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
body.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
+1 -1
View File
@@ -100,7 +100,7 @@
},
{
"command": "cowriting.pinDiffBaseline",
"when": "false"
"when": "editorLangId == markdown"
}
],
"editor/context": [
+48 -14
View File
@@ -18,6 +18,17 @@ import { renderReview, renderPlain, diffBlocks, type BlockOp } from "./trackChan
const VIEW_TYPE = "cowriting.trackChangesPreview";
const DEBOUNCE_MS = 150;
/**
* Inbound webview→host messages (intent only — the sealed webview never mutates,
* INV-21/35). F10 carried the annotations toggle + ✓/✗ proposal decisions; F11
* adds the toolbar intents (pin baseline / ask Claude).
*/
type ToolbarMsg =
| { type: "setMode"; mode: "on" | "off" }
| { type: "accept"; proposalId: string }
| { type: "reject"; proposalId: string }
| { type: "pinBaseline" };
export class TrackChangesPreviewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly panels = new Map<string, vscode.WebviewPanel>();
@@ -91,21 +102,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
null,
this.disposables,
);
// F10: the webview posts the annotations toggle + ✓/✗ proposal decisions back.
// F10/F11: the webview posts the annotations toggle, ✓/✗ proposal decisions,
// and (F11) the toolbar intents (pin baseline / ask Claude) back to the host.
panel.webview.onDidReceiveMessage(
(m: { type?: string; mode?: "on" | "off"; proposalId?: string }) => {
if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) {
this.mode.set(key, m.mode);
this.refresh(document);
} else if (m?.type === "accept" && m.proposalId) {
void this.proposals
.acceptById(this.proposals.keyFor(document), m.proposalId)
.then(() => this.refresh(document));
} else if (m?.type === "reject" && m.proposalId) {
this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId);
this.refresh(document);
}
},
(m: ToolbarMsg) => this.handleWebviewMessage(document, m),
null,
this.disposables,
);
@@ -115,6 +115,30 @@ export class TrackChangesPreviewController implements vscode.Disposable {
this.refresh(document);
}
/**
* Route an inbound webview intent through the existing seams (INV-35): the
* annotations toggle + ✓/✗ proposal decisions (F10) and the toolbar gestures
* (F11). Pin targets the PREVIEWED document (`DiffViewController.pin`), not the
* active editor — the preview knows its bound doc (§6.7).
*/
private handleWebviewMessage(document: vscode.TextDocument, m: ToolbarMsg): void {
const key = document.uri.toString();
if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) {
this.mode.set(key, m.mode);
this.refresh(document);
} else if (m?.type === "accept" && m.proposalId) {
void this.proposals
.acceptById(this.proposals.keyFor(document), m.proposalId)
.then(() => this.refresh(document));
} else if (m?.type === "reject" && m.proposalId) {
this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId);
this.refresh(document);
} else if (m?.type === "pinBaseline") {
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
this.diffView.pin(document);
}
}
private onEdit(document: vscode.TextDocument): void {
const key = document.uri.toString();
if (!this.panels.has(key)) return;
@@ -232,6 +256,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
<body>
<div id="cw-header">
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
<button id="cw-pin" type="button" title="Pin the review baseline to now (clears the change-marks)">⌖ Pin baseline</button>
<span id="cw-epoch">Review</span>
<span id="cw-summary"></span>
<span id="cw-legend"></span>
@@ -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);
}
+69
View File
@@ -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<CowritingApi> {
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");
});
});