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:
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -100,7 +100,7 @@
|
||||
},
|
||||
{
|
||||
"command": "cowriting.pinDiffBaseline",
|
||||
"when": "false"
|
||||
"when": "editorLangId == markdown"
|
||||
}
|
||||
],
|
||||
"editor/context": [
|
||||
|
||||
+44
-10
@@ -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,9 +102,27 @@ 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 }) => {
|
||||
(m: ToolbarMsg) => this.handleWebviewMessage(document, m),
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
this.panels.set(key, panel);
|
||||
// A panel is now open for this doc — the off-panel indicator is redundant.
|
||||
this.hideStatus();
|
||||
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);
|
||||
@@ -104,15 +133,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
} 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);
|
||||
}
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
this.panels.set(key, panel);
|
||||
// A panel is now open for this doc — the off-panel indicator is redundant.
|
||||
this.hideStatus();
|
||||
this.refresh(document);
|
||||
}
|
||||
|
||||
private onEdit(document: vscode.TextDocument): void {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user