feat(f11): SLICE-5 — gateway, non-authorable disabling, docs (#43)

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) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-12 13:57:28 -07:00
parent 03b61ed43e
commit 1564ef562b
6 changed files with 178 additions and 6 deletions
+30 -2
View File
@@ -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 Features shipped so far: F2 region-anchored threads (Feature #4), F3 live
human/Claude attribution (Feature #6), F4 propose/accept diff flow human/Claude attribution (Feature #6), F4 propose/accept diff flow
(Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view (Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view
toggle (Feature #17), and F10 interactive review — **write left / review toggle (Feature #17), F10 interactive review — **write left / review
right** (Feature #29). right** (Feature #29), and F11 — the **preview toolbar as the primary
interaction surface** (Feature #43).
## Architecture ## Architecture
@@ -262,6 +263,33 @@ they are no longer separate user surfaces.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-interactive-review.md`. Design: `vscode-cowriting-plugin-content/specs/coauthoring-interactive-review.md`.
Live smoke: [`docs/MANUAL-SMOKE-F10.md`](docs/MANUAL-SMOKE-F10.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 ## Develop
- `npm run watch` — rebuild on change. - `npm run watch` — rebuild on change.
+67
View File
@@ -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 <time>`**. "What changed" now counts from this moment.
7. **Off-state still maps (INV-36).** Toggle **Annotations** off (clean preview).
Selecting a paragraph still flips the button to **Edit Selection** and an edit
still works — the off/clean preview is also a selection→source surface.
8. **Non-authorable doc (PUC-1/7).** Open a markdown doc from a read-only/virtual
source (e.g. a Git diff view, or an Output channel rendered as markdown) and
open the preview. Expect: **Pin baseline** and **Ask-Claude** render
**disabled**; the **Annotations** toggle stays active (reading is allowed).
9. **Theme.** Toggle light / dark / high-contrast (`Ctrl+K Ctrl+T`). The two
toolbar buttons (enabled, hover, disabled states) restyle and stay legible.
10. **Cleanliness.** `git status` shows nothing written to the document, sidecar,
or repo by the toolbar gestures except the edits you explicitly accepted
(INV-20/35).
## Pass criteria
All ten steps behave as described; the adaptive label tracks the live selection;
the selection-scoped edit anchors at the right paragraph; the document edit
produces per-hunk proposals; pin clears the marks; edit controls are inert on a
non-authorable doc; no console errors in the webview devtools; nothing is
persisted by the toolbar beyond the edits you accepted.
+6
View File
@@ -18,6 +18,8 @@ interface RenderMessage {
html: string; html: string;
epoch?: string; epoch?: string;
summary?: { added: number; removed: number; proposals: number }; summary?: { added: number; removed: number; proposals: number };
/** F11: false on a non-authorable doc → Pin + Ask-Claude controls disabled. */
authorable?: boolean;
} }
const vscodeApi = acquireVsCodeApi(); const vscodeApi = acquireVsCodeApi();
@@ -124,6 +126,10 @@ window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
if (msg?.type !== "render") return; if (msg?.type !== "render") return;
body.innerHTML = msg.html; body.innerHTML = msg.html;
updateAskLabel(); // new content clears any selection → reset the adaptive label updateAskLabel(); // new content clears any selection → reset the adaptive label
// F11 (PUC-1/7): disable edit controls on a non-authorable doc (reading stays on).
const authorable = msg.authorable !== false;
if (pinEl) pinEl.disabled = !authorable;
if (askEl) askEl.disabled = !authorable;
const on = msg.mode === "on"; const on = msg.mode === "on";
if (annotationsEl) annotationsEl.checked = on; if (annotationsEl) annotationsEl.checked = on;
// Off-state is a clean preview: hide the review chrome. // Off-state is a clean preview: hide the review chrome.
+7
View File
@@ -112,6 +112,13 @@
"when": "editorLangId == markdown" "when": "editorLangId == markdown"
} }
], ],
"editor/title": [
{
"command": "cowriting.showTrackChangesPreview",
"when": "editorLangId == markdown",
"group": "navigation@9"
}
],
"editor/context": [ "editor/context": [
{ {
"command": "cowriting.editSelection", "command": "cowriting.editSelection",
+20 -4
View File
@@ -15,6 +15,7 @@ import type { AttributionController } from "./attributionController";
import type { ProposalController } from "./proposalController"; import type { ProposalController } from "./proposalController";
import { renderReview, renderPlain, diffBlocks, diffToHunks, type BlockOp } from "./trackChangesModel"; import { renderReview, renderPlain, diffBlocks, diffToHunks, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer"; import { buildFingerprint } from "./anchorer";
import { isAuthorable } from "./workspacePath";
import type { EditTurnResult } from "./liveTurn"; import type { EditTurnResult } from "./liveTurn";
/** F11: a host edit turn (selection/document text + instruction → rewrite). Injectable for tests. */ /** F11: a host edit turn (selection/document text + instruction → rewrite). Injectable for tests. */
@@ -63,9 +64,12 @@ export class TrackChangesPreviewController implements vscode.Disposable {
private readonly proposals: ProposalController, private readonly proposals: ProposalController,
) { ) {
this.disposables.push( this.disposables.push(
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () => // F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
this.show(vscode.window.activeTextEditor?.document), // the palette / keybinding pass nothing → fall back to the active editor.
), vscode.commands.registerCommand("cowriting.showTrackChangesPreview", (uri?: vscode.Uri) => {
const byUri = uri && vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
this.show(byUri || vscode.window.activeTextEditor?.document);
}),
// F11: document-scoped Ask-Claude (also reused by #42's gateway). Edits the // 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. // active markdown doc; the rewrite is diffed into per-hunk F4 proposals.
vscode.commands.registerCommand("cowriting.editDocument", () => { vscode.commands.registerCommand("cowriting.editDocument", () => {
@@ -265,8 +269,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
const baselineText = baseline?.text ?? current; // no baseline → no change-marks const baselineText = baseline?.text ?? current; // no baseline → no change-marks
const ops = diffBlocks(baselineText, current); const ops = diffBlocks(baselineText, current);
this.lastModel.set(key, ops); this.lastModel.set(key, ops);
// F11 (PUC-1/7): edit controls are inert on a non-authorable doc (reading stays allowed).
const authorable = isAuthorable(document.uri.scheme);
if (mode === "off") { if (mode === "off") {
void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current) }); void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current), authorable });
return; return;
} }
const spans = this.attribution.spansFor(document); const spans = this.attribution.spansFor(document);
@@ -282,6 +288,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
html: renderReview(baselineText, current, spans, proposals), html: renderReview(baselineText, current, spans, proposals),
epoch: this.epochLabel(baseline), epoch: this.epochLabel(baseline),
summary, summary,
authorable,
}); });
} }
@@ -381,6 +388,15 @@ export class TrackChangesPreviewController implements vscode.Disposable {
setEditTurnForTest(fn: EditTurn): void { setEditTurnForTest(fn: EditTurn): void {
this.editTurn = fn; this.editTurn = fn;
} }
/**
* F11 (PUC-1/7): whether the previewed doc's edit controls (Pin + Ask-Claude)
* are enabled true only for an authorable doc. The annotations toggle is
* always active (reading is always allowed). False if no panel/doc.
*/
editControlsEnabled(uriString: string): boolean {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
return doc ? isAuthorable(doc.uri.scheme) : false;
}
getLastModel(uriString: string): BlockOp[] | undefined { getLastModel(uriString: string): BlockOp[] | undefined {
return this.lastModel.get(uriString); return this.lastModel.get(uriString);
} }
+48
View File
@@ -155,4 +155,52 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
assert.ok(entry, "editDocument has a commandPalette entry"); assert.ok(entry, "editDocument has a commandPalette entry");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
}); });
// SLICE-5: the minimal right-click gateway lives in editor/title (markdown only).
test("the editor/title gateway opens the preview, and the menu entry is markdown-guarded (PUC-6)", async () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus["editor/title"] as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.showTrackChangesPreview",
);
assert.ok(entry, "showTrackChangesPreview is in editor/title");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "gateway guarded on markdown");
// the command it invokes opens the panel.
const { key } = await freshDoc("docs/f11gw.md", "# F11 gateway\n\nReachable end to end.\n");
const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "gateway command opens the preview");
});
// SLICE-5: edit controls are inert on a non-authorable (read-only scheme) doc.
test("toolbar edit controls are disabled for a non-authorable document (PUC-1/7)", async () => {
const SCHEME = "cwf11ro";
const provider = new (class implements vscode.TextDocumentContentProvider {
onDidChange = undefined;
provideTextDocumentContent(): string {
return "# Read only\n\nThis markdown doc is not authorable.\n";
}
})();
const reg = vscode.workspace.registerTextDocumentContentProvider(SCHEME, provider);
try {
const uri = vscode.Uri.parse(`${SCHEME}:/readonly.md`);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.languages.setTextDocumentLanguage(doc, "markdown");
await vscode.window.showTextDocument(doc);
await settle();
const api = await getApi();
const key = uri.toString();
assert.strictEqual(doc.languageId, "markdown", "fixture is markdown");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "preview opens (reading is always allowed)");
assert.strictEqual(
api.trackChangesPreviewController.editControlsEnabled(key),
false,
"Pin + Ask-Claude controls are disabled on a non-authorable doc",
);
} finally {
reg.dispose();
}
});
}); });