603 lines
37 KiB
Markdown
603 lines
37 KiB
Markdown
---
|
||
status: graduated
|
||
---
|
||
# Solution Design: Preview Toolbar as the Primary Interaction Surface (F11)
|
||
|
||
| | |
|
||
| --- | --- |
|
||
| **Author(s)** | Ben Stull (with Claude) |
|
||
| **Reviewers / approvers** | Ben Stull |
|
||
| **Status** | `draft` |
|
||
| **Version** | v0.1.0 |
|
||
| **Source artifacts** | Feature `benstull/vscode-cowriting-plugin#43` (F11, `type/feature`, `priority/P1`) · Epic `#1` (closed) · Capture session `vscode-cowriting-plugin-0035` (2026-06-12) · Brainstorming session `vscode-cowriting-plugin-0036` · Builds on (all shipped): F3 `#6` (live attribution), F4 `#12` (propose/accept), F6 `#17`/`#19` (baseline + diff view), F7 `#21`/`#22` (rendered preview), F9 `#27` (authorship preview), F10 `#29` (interactive review preview), F10-followups `#31` (inline-at-anchor proposals) · Coexists with `#41` (right-click → Open Review Panel) and `#42` (right-click → Ask Claude to Edit), both blocked-by this · Parent specs (graduated): `coauthoring-inner-loop.md`, `coauthoring-attribution.md`, `coauthoring-propose-accept.md`, `coauthoring-diff-view.md`, `coauthoring-rendered-preview.md`, `coauthoring-interactive-review.md` · Lineage: `ben.stull/rfc-app#48` |
|
||
|
||
**Change log**
|
||
|
||
| Date | Version | Change | By |
|
||
| --- | --- | --- | --- |
|
||
| 2026-06-12 | v0.1.0 | Initial draft — brainstorming session 0036 (from the capture in session 0035). Three forks locked with the operator: block-level preview-selection→source mapping; document edit diffed into per-hunk F4 proposals; #43 lands a minimal right-click→open-preview gateway. | Ben Stull + Claude |
|
||
|
||
---
|
||
|
||
## 1. Business Context
|
||
|
||
### 1.1 Executive Summary
|
||
|
||
F10 made the rendered preview the **single review surface** — clean editor on the
|
||
left, annotated review on the right, with an annotations on/off toggle and
|
||
inline ✓/✗ on Claude's pending proposals. But the writer still cannot *act* from
|
||
the preview beyond accepting/rejecting proposals: to **ask Claude to edit**, they
|
||
jump back to the editor and use a selection-gated context-menu item; to **pin a
|
||
fresh review baseline** they have *no* reachable control at all (the
|
||
`cowriting.pinDiffBaseline` command is registered but `when:false`, orphaned
|
||
since `#34` removed its two-pane host); and whole-document editing doesn't exist.
|
||
|
||
F11 makes the **preview toolbar the primary interaction surface**. Beside the
|
||
existing annotations checkbox — the one control the writer already loves — the
|
||
toolbar gains a **Pin baseline** button and a **single adaptive "Ask Claude…"
|
||
button** that reads *Edit Selection* when text is selected in the preview and
|
||
*Edit Document* when nothing is. The writer reads, asks Claude to edit, and
|
||
resets the baseline all in one place, mouse-first, without leaving the rendered
|
||
document. A minimal right-click entry opens the preview, making it the surface
|
||
the `#41`/`#42` gateways will lead into.
|
||
|
||
### 1.2 Background
|
||
|
||
The inner loop shipped F2–F5 (threads · attribution · propose/accept ·
|
||
cross-rung). F6 added the baseline + a native diff toggle; F7 the rendered
|
||
track-changes preview; F9 an authorship mode; F10 (`#29`) collapsed those into the
|
||
**single interactive review preview** (clean editor; annotations on/off; ✓/✗ on
|
||
F4 proposals surfaced in the rendered view); `#31` then placed proposals
|
||
**inline at their resolved anchor** in that preview.
|
||
|
||
Capture session 0035 filed `#43` (this feature) plus `#41` (right-click → Open
|
||
Review Panel) and `#42` (right-click → Ask Claude to Edit). The operator's ask:
|
||
*"Can we set it up so all interactions — Ask Claude to Edit Selection / Edit
|
||
Document, annotations off/on, Pin new baseline — are via the preview window? I
|
||
like the annotations checkbox up there; make the others buttons, with one 'Ask
|
||
Claude…' button that changes depending on whether some of the markdown preview is
|
||
selected."* That session also surfaced the stranded `pinDiffBaseline` command
|
||
(`when:false` since `#34`). This spec is the Solution Design for `#43`.
|
||
|
||
### 1.3 Business Actors / Roles
|
||
|
||
- **Coauthor (human)** — the markdown writer/engineer (PP-1); F11's sole user.
|
||
- **Coauthor (machine)** — Claude via `@cline/sdk`; not a user of F11, but the
|
||
target of the toolbar's "Ask Claude…" gesture and the author of the proposals
|
||
that result.
|
||
|
||
### 1.4 Problem Statement
|
||
|
||
The plugin's interactions are scattered across surfaces and inconsistently
|
||
reachable. "Ask Claude to Edit Selection" is only a selection-gated **editor**
|
||
context-menu item; **whole-document editing doesn't exist**; and **Pin Review
|
||
Baseline** is **unreachable from any UI**. The one control the writer loves — the
|
||
annotations on/off checkbox in the preview — proves the toolbar is the natural
|
||
home for these gestures, but it stands alone. A writer reviewing in the preview
|
||
has to leave it and hunt through editor menus / the palette to act.
|
||
|
||
### 1.5 Pain Points
|
||
|
||
- **No edit gesture in the preview** — to ask Claude to change anything, the
|
||
writer leaves the review surface for the editor's right-click menu.
|
||
- **Whole-document editing is missing** — there is no "edit the whole document"
|
||
path at all; only a selection-scoped editor command exists.
|
||
- **Pin baseline is stranded** — the command exists but no menu, keybinding, or
|
||
palette entry reaches it (`when:false` since `#34`).
|
||
- **Mouse-first review is broken mid-flow** — the preview is mouse-driven, but
|
||
acting forces a context-switch to keyboard/menus elsewhere.
|
||
|
||
### 1.6 Targeted Business Outcomes
|
||
|
||
The preview becomes a **self-contained cockpit** for the inner loop. From its
|
||
toolbar a writer can toggle annotations (today), **ask Claude to edit** (selection
|
||
or whole document, via one button that adapts to what's selected), and **pin a
|
||
fresh baseline** — no context-switching to the editor or command palette. The
|
||
interaction model consolidates around the surface the writer already prefers, and
|
||
the stranded pin command gets a real home.
|
||
|
||
### 1.7 Scope (business)
|
||
|
||
**In scope:** preview-webview toolbar controls — a **Pin baseline** button and a
|
||
**single adaptive "Ask Claude…" button** (Edit Selection ⇆ Edit Document) — beside
|
||
the existing annotations checkbox; wiring those controls to the **existing** F4
|
||
edit seam, F3 attribution, and F6 baseline command; **block-level**
|
||
preview-selection → source-range mapping (the central design risk); a **new
|
||
whole-document edit path** whose result is **diffed into per-hunk F4 proposals**;
|
||
a **minimal right-click → Open Review Preview** entry so the surface is reachable
|
||
end-to-end; resolving the pin-baseline reachability gap; unit + host-E2E coverage;
|
||
manual webview smoke.
|
||
|
||
**Out of scope (deferred, not forgotten):** **char-precise sub-block** selection
|
||
mapping (block granularity is the locked v1 — §6.7); the **richer `#41`/`#42`
|
||
menu sets** (this feature lands only the minimal gateway; `#41`/`#42` expand it);
|
||
preview→source **scroll-sync** (`#32`); multi-file / batch editing; the Explorer
|
||
tree affordance; any export / print / copy gesture.
|
||
|
||
**Non-goals (firm):** **no new edit / attribution / proposal *model*** — F11
|
||
reuses F3 `spansFor`, the F4 `propose`/`accept` single-range model, and the F6
|
||
baseline store; no change to the **sidecar**, the **cross-rung contract**, or
|
||
`SCHEMA_VERSION`; **no document mutation from the webview** (INV-20/21/34 hold —
|
||
the sealed webview posts intent only); no LLM/network/credential surface added to
|
||
the webview (INV-8 untouched — the edit turn runs host-side as today).
|
||
|
||
### 1.8 Assumptions · Constraints · Dependencies
|
||
|
||
- **Anchor:** Feature `#43` (F11). Builds directly on shipped work: the F7/F10
|
||
rendered preview + annotations toggle + host↔webview message bus, the F3
|
||
attribution + F4 propose/accept inner loop (including `#31`'s inline-at-anchor
|
||
proposal placement), and the F6 baseline store (`cowriting.pinDiffBaseline`,
|
||
currently unreachable — this feature gives it a home).
|
||
- **Central design risk (locked):** the preview is a **rendered** sealed webview
|
||
(markdown-it HTML, strict CSP — F7 INV-21), so "Edit Selection" must map a
|
||
selection in the rendered preview back to a **source markdown range**. The
|
||
rendered HTML carries **no** source positions today; only internal block
|
||
char-offsets exist (`splitBlocksWithRanges` → `BlockWithRange.start/end`). The
|
||
locked approach is **block-level** mapping: the pure render layer emits
|
||
`data-src-start`/`data-src-end` on each rendered block; a selection resolves to
|
||
the union of the live-source blocks it intersects (§6.7, fork 1).
|
||
- **Constraint (sealed webview):** interactive controls post messages to the
|
||
extension host, which applies edits/pins via the existing F4 / F6 paths; the
|
||
webview **never** edits the document, sidecar, or baseline directly. The edit
|
||
**turn** (LLM call) and the **instruction prompt** run host-side, keeping the
|
||
webview free of LLM/network/credential surface.
|
||
- **Coexistence:** the native editor context-menu "Ask Claude to Edit Selection"
|
||
(`cowriting.editSelection`) stays unchanged; `#41`/`#42` add right-click
|
||
gateways *into* the preview. F11 lands only a minimal gateway (§6.7, fork 3).
|
||
- No new persisted artifact; nothing in `.threads/`, the contract, or
|
||
`SCHEMA_VERSION` changes.
|
||
|
||
### 1.9 Business Use Cases
|
||
|
||
- **BUC-1 (edit from the preview)** Reviewing in the preview, the writer selects a
|
||
paragraph in the rendered document and clicks **"Ask Claude to Edit
|
||
Selection"**; Claude's proposed change appears as a blue ✓/✗ block at that
|
||
spot — without the writer ever leaving the preview.
|
||
- **BUC-2 (edit the whole document)** With nothing selected, the writer clicks
|
||
**"Ask Claude to Edit Document"**, types an instruction, and Claude's rewrite
|
||
surfaces as **several** independently-acceptable blue proposal blocks (one per
|
||
changed hunk) inline in the preview.
|
||
- **BUC-3 (pin a fresh baseline)** After accepting a batch of changes, the writer
|
||
clicks **"Pin baseline"**; the change-marks clear and "what changed" now counts
|
||
from this moment — the stranded command finally has a button.
|
||
|
||
---
|
||
|
||
## 2. Solution Proposal
|
||
|
||
F11 is a **thin increment** on F10's preview: it adds two controls to the
|
||
existing header toolbar and routes their intent through machinery that already
|
||
exists. No new model, no new persisted state.
|
||
|
||
**The pure render layer (`trackChangesModel.ts`) learns one new thing:** a shared
|
||
helper wraps each rendered block in `<div data-src-start="N" data-src-end="M">`
|
||
using the existing `splitBlocksWithRanges` offsets. It is applied to **both**
|
||
render paths — `renderReview` (annotations on) and `renderPlain` (annotations
|
||
off) — so selection→source mapping works in either mode. The helper is pure,
|
||
vscode-free, DOM-free, and deterministic (extends INV-22/33). The render layer
|
||
gains **no** selection or DOM logic.
|
||
|
||
**The webview (`media/preview.ts` + `.css`)** header becomes:
|
||
|
||
```
|
||
[ ☑ Annotations ] [ ⌖ Pin baseline ] [ ✦ Ask Claude to Edit Document ▾ ]
|
||
```
|
||
|
||
The **Ask-Claude button morphs its own label** on `selectionchange`: a non-empty
|
||
selection inside the rendered body → **"Ask Claude to Edit Selection"**; an empty
|
||
/ collapsed selection → **"Ask Claude to Edit Document"**. On click it walks the
|
||
selection's start and end nodes up to the nearest ancestor carrying
|
||
`data-src-start`/`data-src-end` and posts the resolved offsets. That
|
||
nearest-ancestor lookup is the webview's **only** mapping duty (manual-smoke
|
||
territory); everything downstream is host-side and testable. The webview stays
|
||
**sealed** (INV-21): nonce'd inline script, no network, no document mutation.
|
||
|
||
**New webview→host messages (intent only):**
|
||
|
||
```ts
|
||
type ToolbarMsg =
|
||
| { type: "pinBaseline" }
|
||
| { type: "askClaude"; scope: "selection"; start: number; end: number }
|
||
| { type: "askClaude"; scope: "document" };
|
||
```
|
||
|
||
**The host (`trackChangesPreview.ts`)** routes each intent through the existing
|
||
seams:
|
||
|
||
- **`pinBaseline`** → pins the **previewed** document (calls
|
||
`DiffViewController.pin(document)` directly — not the `activeTextEditor`-based
|
||
command, which may not point at the previewed doc) → `onDidChangeBaseline` →
|
||
re-render with cleared marks.
|
||
- **`askClaude`** → host `showInputBox` for the instruction (keeps the LLM /
|
||
secret surface out of the sealed webview), then one shared host routine
|
||
`runEditAndPropose(document, target, instruction)`:
|
||
- **selection** → `target` = the block-union range `[firstBlock.start …
|
||
lastBlock.end]`; one `runEditTurn` → one F4 `propose()` over that range (the
|
||
existing Edit-Selection shape).
|
||
- **document** → `target` = whole document; one `runEditTurn` over the full
|
||
text → **diff Claude's result against the current text → one `propose()` per
|
||
changed hunk** (multiple proposals, each its own blue ✓/✗ block). Reuses the
|
||
F4 single-range model N times; no model change.
|
||
|
||
**The right-click gateway:** `cowriting.showTrackChangesPreview` is added to the
|
||
`editor/title` menu (markdown only) so right-clicking the tab opens the preview —
|
||
the minimal entry that makes the toolbar surface reachable end-to-end and lets
|
||
`#41`/`#42` expand the menu set later.
|
||
|
||
**Reachability cleanup:** `cowriting.pinDiffBaseline` gets a real palette `when`
|
||
(`editorLangId == markdown`), resolving the orphan from the command side too; a
|
||
new `cowriting.editDocument` command is registered (document-scoped edit) so
|
||
`#42`'s gateway can reuse it.
|
||
|
||
Everything downstream of *(intent) → (existing seam)* is the existing F4/F6/F3
|
||
machinery; the only genuinely new pure code is the block-offset wrapper and the
|
||
document-rewrite hunk-diff. Both are unit-testable with no vscode and no webview.
|
||
|
||
---
|
||
|
||
## 3. Product Personas
|
||
|
||
- **PP-1 Inner-loop coauthor** — the human markdown writer/engineer (as F2–F10);
|
||
the only persona F11 serves.
|
||
|
||
## 4. Product Use Cases
|
||
|
||
- **PUC-1 (toolbar present)** Opening the review preview for a markdown document
|
||
shows the header with **three** controls: the annotations on/off checkbox
|
||
(existing), a **Pin baseline** button, and an adaptive **Ask Claude…** button.
|
||
Controls are inert (disabled) for non-authorable documents.
|
||
- **PUC-2 (adaptive label)** With a non-empty selection in the rendered preview
|
||
body, the Ask-Claude button reads **"Ask Claude to Edit Selection"**; with no
|
||
selection it reads **"Ask Claude to Edit Document"**. The label flips live as
|
||
the selection changes.
|
||
- **PUC-3 (edit selection)** The writer selects rendered text, clicks **Ask
|
||
Claude to Edit Selection**, and enters an instruction. The selection resolves to
|
||
the union of the source blocks it touches; Claude proposes a change over that
|
||
range; a single blue ✓/✗ proposal block appears inline at that anchor (`#31`).
|
||
- **PUC-4 (edit document)** With nothing selected, the writer clicks **Ask Claude
|
||
to Edit Document**, enters an instruction; Claude rewrites the whole document;
|
||
the rewrite is diffed into hunks and surfaces as **N** independent blue ✓/✗
|
||
proposal blocks inline. Accepting/rejecting each is the F10 path unchanged.
|
||
- **PUC-5 (pin baseline)** The writer clicks **Pin baseline**; the previewed
|
||
document's review baseline is pinned to now; the change-marks clear and the
|
||
`Since <epoch>` label updates. (No confirmation prompt — matches the existing
|
||
command's behavior; re-pinning is the recovery.)
|
||
- **PUC-6 (right-click into the preview)** Right-clicking a markdown editor tab
|
||
shows **Open Review Preview**; choosing it opens the preview (the gateway
|
||
`#41`/`#42` will build upon).
|
||
- **PUC-7 (graceful edges)** A selection confined to a deletion (struck) or
|
||
proposal block — which carries no live-source range — falls back to **document**
|
||
scope. An empty document or a selection that resolves to no live block → the
|
||
button stays in **Edit Document** mode. A non-authorable document → toolbar edit
|
||
controls are disabled. The LLM turn failing → the existing `runEditTurn`
|
||
error handling (no proposal created); the preview is unchanged.
|
||
|
||
---
|
||
|
||
## 5. UX Layout
|
||
|
||
The F10 preview is unchanged except for its **header bar**, which now hosts three
|
||
controls in a single row:
|
||
|
||
- **☑ Annotations** — the existing on/off checkbox (kept first; the operator's
|
||
preferred control).
|
||
- **⌖ Pin baseline** — a button; pins the previewed document's review baseline to
|
||
now and clears the change-marks.
|
||
- **✦ Ask Claude to Edit Document ▾** — a single button whose label and behavior
|
||
adapt to the preview's selection state (Edit **Selection** when text is
|
||
selected, Edit **Document** otherwise). Clicking it opens a host input box for
|
||
the instruction.
|
||
|
||
Buttons are styled with theme CSS variables (light / dark / high-contrast),
|
||
matching the existing toolbar chrome; they sit in the same `#cw-toggle` header
|
||
region as the annotations checkbox. When the previewed document is not authorable
|
||
(F8 `isAuthorable`), the **Pin baseline** and **Ask Claude…** controls render
|
||
**disabled** (the annotations toggle stays active — reading is always allowed).
|
||
|
||
The rendered body is unchanged from F10/`#31`: green human additions, blue
|
||
LLM-authored text, struck deletions, and pending Claude proposals as inline blue
|
||
blocks with ✓/✗ at their resolved anchors. Proposals produced via the new toolbar
|
||
edit gestures appear exactly as proposals do today.
|
||
|
||
---
|
||
|
||
## 6. Technical Design
|
||
|
||
### 6.1 Invariants
|
||
|
||
Parent invariants INV-1..INV-34 carry over unchanged. F11 adds:
|
||
|
||
- **INV-35 (toolbar gestures route through existing seams; webview never
|
||
mutates)** The Pin baseline and Ask-Claude toolbar controls post **intent**
|
||
messages to the host; **all** mutation goes through the existing machinery — pin
|
||
via the F6 baseline store (`DiffViewController.pin`), edits via the F4
|
||
`propose` → `accept`/`applyAgentEdit` (`WorkspaceEdit`) seam with F3 attribution.
|
||
No divergent edit or baseline path is introduced. The sealed webview never
|
||
edits the document, sidecar, or baseline directly (INV-20/21/34 hold); the LLM
|
||
turn and the instruction prompt run host-side (INV-8 untouched).
|
||
- **INV-36 (block-granular preview-selection → source mapping)** The pure render
|
||
layer emits `data-src-start`/`data-src-end` (source char offsets from
|
||
`BlockWithRange`) on **every** rendered block, in **both** the on (`renderReview`)
|
||
and off (`renderPlain`) modes. A preview selection resolves to the **union of
|
||
the live-source blocks it intersects** (`[min start … max end]`); blocks with no
|
||
live-source range (deletion-only / proposal blocks) are skipped, and a selection
|
||
that resolves to no live block falls back to **document** scope. The DOM
|
||
selection → nearest-`data-src` lookup is the webview's **sole** mapping duty;
|
||
the offsets and everything downstream (fingerprint, turn, propose) are host-side
|
||
and testable. The wrapping is deterministic — same inputs → identical HTML
|
||
(extends INV-22/33).
|
||
- **INV-37 (single adaptive Ask-Claude button; scope-aware)** One toolbar button
|
||
serves both scopes. A non-empty live-source selection → **Edit Selection**: one
|
||
F4 proposal over the block-union range. An empty selection → **Edit Document**:
|
||
one `runEditTurn` over the whole document, its result **diffed into hunks**, one
|
||
F4 `propose()` per changed hunk. Both scopes call the same host
|
||
`runEditAndPropose` routine and reuse the F4 **single-range** proposal model
|
||
(the document case issues multiple single-range proposals — **no new model**).
|
||
|
||
### 6.2 High-level architecture
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
wv["webview header\n☑ Annotations · ⌖ Pin · ✦ Ask Claude (adaptive)"] -- "postMessage{pinBaseline | askClaude(scope,start?,end?)}" --> ctl["trackChangesPreview\n(vscode layer)"]
|
||
ctl -- "pin(document)" --> base["F6 DiffViewController\nbaseline store (INV-18)"]
|
||
ctl -- "showInputBox → runEditAndPropose" --> turn["runEditTurn\n(host-side LLM turn)"]
|
||
turn -- "selection: 1 replacement\ndocument: rewrite" --> ctl
|
||
ctl -- "selection → 1 propose()\ndocument → diff → N propose()" --> prop["F4 ProposalController\npropose() (single-range model)"]
|
||
prop -- "onDidChangeProposals" --> ctl
|
||
base -- "onDidChangeBaseline" --> ctl
|
||
ctl -- "(baseline, current, spans, proposals)" --> model["renderReview / renderPlain\n(pure)\n+ wrapBlocksWithSrc (NEW)"]
|
||
model -- "annotated HTML w/ data-src on blocks" --> ctl
|
||
ctl -- "postMessage{render}" --> wv
|
||
```
|
||
|
||
The dashed-in NEW pieces are: `wrapBlocksWithSrc` (pure), the
|
||
`runEditAndPropose` host routine with its document-rewrite hunk-diff, the three
|
||
inbound toolbar messages, and the `editor/title` gateway menu. Everything else is
|
||
the existing F6/F4/F3/F10 machinery.
|
||
|
||
### 6.3 Data model & ownership
|
||
|
||
**No new persisted artifact** (INV-20). F11 adds only transient on-the-wire
|
||
messages (the `ToolbarMsg` union in §2) and reuses F10's `RenderMsg`. Baseline is
|
||
owned by F6, proposals by F4 (sidecar), attribution by F3 — all untouched. The
|
||
block-offset `data-src` attributes are render-time only (not stored).
|
||
|
||
### 6.4 Interfaces & contracts
|
||
|
||
- **`trackChangesModel`** (vscode-free, pure): new
|
||
`wrapBlocksWithSrc(blocks: BlockWithRange[], renderedPerBlock: string[]):
|
||
string` (illustrative) — or, more precisely, both `renderReview` and
|
||
`renderPlain` route their per-block rendered HTML through a shared internal
|
||
helper that prepends `data-src-start`/`data-src-end` to each block's wrapping
|
||
element. Plus `diffToHunks(currentText: string, rewrittenText: string):
|
||
Array<{ start: number; end: number; replacement: string }>` — the pure
|
||
document-rewrite → per-hunk proposal-range list (vscode-free, deterministic).
|
||
- **`TrackChangesPreviewController`** (vscode layer): handles the three new
|
||
inbound messages; gains a `runEditAndPropose(document, target: { kind:
|
||
"range"; start; end } | { kind: "document" }, instruction)` private routine;
|
||
takes (or reaches) the `DiffViewController` to pin the previewed doc and the
|
||
edit-turn entry. New test seams as needed (`getLastModel` already exists for
|
||
asserting marks without webview DOM).
|
||
- **`DiffViewController`** (F6): `pin(document)` is reused as-is (the controller
|
||
already exposes pinning by document); `cowriting.pinDiffBaseline`'s
|
||
`package.json` `when` flips from `false` to `editorLangId == markdown`.
|
||
- **`ProposalController`** (F4): `propose(...)` reused unchanged (called once for
|
||
selection, N times for a diffed document). No signature change.
|
||
- **`AttributionController`** (F3): unchanged (`applyAgentEdit` reused on accept).
|
||
- **Commands / menus (`package.json`):**
|
||
- `cowriting.showTrackChangesPreview` — added to `editor/title` with
|
||
`when: editorLangId == markdown` (the minimal gateway). Existing palette +
|
||
`ctrl+alt+r` kept.
|
||
- `cowriting.pinDiffBaseline` — `when` flips to `editorLangId == markdown`
|
||
(no longer orphaned).
|
||
- `cowriting.editDocument` ("Ask Claude to Edit Document", document-scoped) —
|
||
new command registered, routed through `runEditAndPropose({kind:"document"})`;
|
||
available for `#42` to reuse. (The preview's selection-scoped edit is driven
|
||
by the `askClaude` message carrying webview-resolved offsets, not a command,
|
||
since the offsets originate in the webview.)
|
||
- **Webview asset** (`media/preview.ts` + `.css`): header gains the two buttons;
|
||
a `selectionchange` listener updates the Ask-Claude label; click handlers post
|
||
the `ToolbarMsg` intents; the selection→nearest-`data-src` lookup helper. Stays
|
||
sealed (nonce'd inline script, CSP unchanged).
|
||
|
||
### 6.5 Per–Product-Use-Case design
|
||
|
||
- **PUC-1 (toolbar present):** render the two buttons in the header next to the
|
||
annotations checkbox; disable Pin + Ask-Claude when `!isAuthorable(document)`.
|
||
- **PUC-2 (adaptive label):** webview `selectionchange` → if the selection is
|
||
non-empty and within the rendered body, label = "Edit Selection"; else "Edit
|
||
Document". Pure webview-local state.
|
||
- **PUC-3 (edit selection):** webview resolves selection → `{start, end}` from the
|
||
nearest `data-src` ancestors → `postMessage{askClaude, selection, start, end}` →
|
||
host `showInputBox` → `runEditAndPropose({kind:"range", start, end})` →
|
||
`runEditTurn` → one `propose()` → `onDidChangeProposals` → re-render (inline
|
||
blue block at the anchor, `#31`).
|
||
- **PUC-4 (edit document):** `postMessage{askClaude, document}` → host input box →
|
||
`runEditAndPropose({kind:"document"})` → `runEditTurn` over full text →
|
||
`diffToHunks(current, rewritten)` → one `propose()` per hunk → re-render (N blue
|
||
blocks).
|
||
- **PUC-5 (pin baseline):** `postMessage{pinBaseline}` → `DiffViewController.pin(
|
||
previewedDocument)` → `onDidChangeBaseline` → re-render (marks cleared).
|
||
- **PUC-6 (right-click gateway):** `editor/title` entry invokes
|
||
`cowriting.showTrackChangesPreview` for the tab's document.
|
||
- **PUC-7 (edges):** selection resolving to no live block → document scope;
|
||
non-authorable → controls disabled; `runEditTurn` failure → existing error path,
|
||
no proposal; empty doc → Edit Document over empty range (no-op-safe).
|
||
|
||
### 6.6 Non-functional requirements & cross-cutting concerns
|
||
|
||
The webview stays **sealed** (INV-21): local assets, strict CSP with a per-load
|
||
nonce, no network; the new inline handlers only read `data-src`/`data-proposal-id`
|
||
and post intent (no eval, no remote, no document mutation). The instruction prompt
|
||
and the LLM turn remain **host-side** — the webview gains **no** LLM, network, or
|
||
credential surface (INV-8 untouched). `diffToHunks` and the block wrapping are
|
||
O(document), run on a host gesture (not per-keystroke), fine at inner-loop scale.
|
||
No telemetry, nothing persisted.
|
||
|
||
### 6.7 Key decisions & alternatives considered
|
||
|
||
| Decision | Chosen | Alternatives rejected |
|
||
| --- | --- | --- |
|
||
| **Preview-selection → source mapping granularity** | **Block-level** — pure layer emits `data-src-start/end` from existing `BlockWithRange`; selection → union of intersected live-source blocks. Robust, reuses what exists, ships the full adaptive button now. *(Operator decision, session 0036.)* | **Char-precise** sub-block mapping — needs per-inline-token source offsets markdown-it doesn't reliably give; rendered text ≠ source (syntax stripped) → fragile, risks the whole feature on the hardest part. **Document-only first** — defers the headline adaptive button; punts the risk. |
|
||
| **Document-edit proposal granularity** | **Diff Claude's rewrite into hunks → one F4 proposal per changed hunk** — independent ✓/✗ per change; reuses the single-range model N times (no model change). *(Operator decision, session 0036.)* | **One whole-document proposal** — a single giant blue block, all-or-nothing accept/reject; poor UX for a real rewrite. |
|
||
| **`#43` vs `#41`/`#42` scope** | **`#43` lands a minimal right-click → Open Review Preview gateway** (`editor/title`), so the toolbar surface is reachable end-to-end and its E2E is real; `#41`/`#42` expand the menu set. *(Operator decision, session 0036.)* | **Toolbar only; all menus in `#41`/`#42`** — `#43`'s "a right-click entry opens the preview" acceptance/E2E couldn't be satisfied within `#43`. |
|
||
| **Instruction prompt location** | **Host `showInputBox`** — keeps LLM/secret surface out of the sealed webview; reuses the existing edit-turn flow. | **In-webview text field** — pushes prompt handling toward the sandbox; no benefit. |
|
||
| **Pin button target** | **The previewed document** (`DiffViewController.pin(document)`) — the preview knows its bound doc. | **`activeTextEditor`-based command** — may not point at the previewed doc; the source of the orphan. |
|
||
| **Pin confirmation** | **No confirm** — matches the existing command; re-pinning recovers. | **Confirm dialog** — friction for a routine, recoverable gesture. |
|
||
|
||
### 6.8 Testing strategy
|
||
|
||
- **Unit (vitest, vscode-free):** `data-src-start/end` present and correct on every
|
||
block for **both** `renderReview` and `renderPlain` (offsets equal the
|
||
`BlockWithRange` ranges; determinism — same inputs → identical HTML);
|
||
`diffToHunks` over fixtures — a single-hunk rewrite → one range; a multi-hunk
|
||
rewrite → the expected disjoint ranges with correct replacements; an unchanged
|
||
rewrite → zero hunks; whole-document replacement → one full-range hunk.
|
||
- **Host E2E (`@vscode/test-electron`, no LLM, extends the F10 suite):** open a
|
||
markdown fixture → `cowriting.showTrackChangesPreview`. Simulate
|
||
`{type:"pinBaseline"}` → `getLastModel` shows cleared change-marks + advanced
|
||
epoch. Simulate `{type:"askClaude", scope:"selection", start, end}` with a
|
||
stubbed edit turn → exactly **one** proposal over the resolved range, anchored
|
||
inline. Simulate `{type:"askClaude", scope:"document"}` with a stubbed
|
||
multi-hunk rewrite → **N** proposals matching the hunks. Invoke the
|
||
`editor/title` gateway command → panel opens. Non-authorable document → toolbar
|
||
edit controls disabled (asserted via the model/flags the host exposes). The
|
||
webview DOM, real button clicks, the `selectionchange` label flip, and the
|
||
selection→`data-src` lookup are **not** E2E-asserted (sealed sandbox) — manual
|
||
smoke.
|
||
- **Live smoke (manual — `docs/MANUAL-SMOKE-F11.md`):** open a markdown doc; open
|
||
the review preview; confirm the three header controls; select a paragraph →
|
||
button reads "Edit Selection", click → enter instruction → a blue ✓/✗ block
|
||
appears at that paragraph; clear the selection → button reads "Edit Document",
|
||
click → instruction → several blue blocks appear; click **Pin baseline** → marks
|
||
clear, `Since` label updates; right-click the tab → **Open Review Preview** opens
|
||
the panel; verify light/dark theming and that `git status` shows nothing
|
||
unexpected.
|
||
|
||
### 6.9 Failure modes, rollback & flags
|
||
|
||
A selection that resolves to no live-source block → **Edit Document** scope (never
|
||
an error). `runEditTurn` failing → existing error handling, no proposal created,
|
||
preview unchanged. `diffToHunks` producing zero hunks (rewrite == current) → no
|
||
proposals, a brief "no changes proposed" host notice. Webview disposed mid-gesture
|
||
→ the host routine completes against the document; the next open re-renders.
|
||
**No feature flag** — the toolbar controls are additive UI; nothing persists.
|
||
Rollback is reverting the PR with **zero** data migration (nothing persisted; the
|
||
F6 baseline, F4 sidecar, F3 attribution data are untouched; the unhidden pin
|
||
command and the gateway menu simply disappear).
|
||
|
||
---
|
||
|
||
## 7. Delivery Plan
|
||
|
||
### 7.1 Approach / strategy
|
||
|
||
One planning-and-executing session (F11 = `#43`), plan written just-in-time from
|
||
this spec — the F2–F10 precedent. Host-E2E tier (a VS Code extension has no
|
||
browser/deploy stage); no LLM in CI (edit turns stubbed). The webview's visual
|
||
rendering, the adaptive label, and the selection→source DOM lookup are verified by
|
||
the manual smoke; the automated seams are the pure block-wrapping + `diffToHunks`
|
||
model and the host's message→seam wiring.
|
||
|
||
### 7.2 Slicing plan
|
||
|
||
- **SLICE-1 — Pin baseline button + reachability.** Webview header **Pin
|
||
baseline** button → `{type:"pinBaseline"}` → host `DiffViewController.pin(
|
||
previewedDoc)`; unhide `cowriting.pinDiffBaseline` (`when: editorLangId ==
|
||
markdown`). Host E2E: pin message clears marks. *(Immediate win — homes the
|
||
orphaned command.)*
|
||
- **SLICE-2 — Block-offset emission.** Shared pure helper wrapping each block with
|
||
`data-src-start/end` in `renderReview` **and** `renderPlain`; vitest for both
|
||
modes + determinism. No UI yet. (INV-36 data layer.)
|
||
- **SLICE-3 — Edit Document button + hunk path.** Webview **Ask Claude to Edit
|
||
Document** button (no-selection state) → `{type:"askClaude", scope:"document"}`;
|
||
host `runEditAndPropose({document})` → `runEditTurn` → `diffToHunks` → N
|
||
`propose()`; register `cowriting.editDocument`; vitest for `diffToHunks`; host
|
||
E2E for the N-proposal path. (INV-37 document half.)
|
||
- **SLICE-4 — Adaptive Edit Selection.** Webview `selectionchange` label flip +
|
||
selection→nearest-`data-src` resolution → `{type:"askClaude", scope:"selection",
|
||
start, end}`; host single-range `propose()`. Host E2E for the selection message →
|
||
one anchored proposal. (INV-37 selection half; INV-36 consumer.)
|
||
- **SLICE-5 — Gateway, edges, tests & docs.** `editor/title` → Open Review Preview
|
||
gateway; non-authorable disabling; host E2E (gateway opens panel; controls
|
||
inert on non-authorable); `docs/MANUAL-SMOKE-F11.md`; README F11 section.
|
||
|
||
E2E are first-class plan tasks (handbook §9/§4); this app's required tier is host
|
||
E2E (the F2–F10 precedent).
|
||
|
||
### 7.3 Rollout / launch plan
|
||
|
||
Non-shippable (no marketplace publish). "Done" = `#43` acceptance: the preview
|
||
toolbar hosts the annotations checkbox + a Pin baseline button + a single adaptive
|
||
Ask-Claude button (Edit Selection ⇆ Edit Document) that route through the existing
|
||
F4/F3/F6 machinery; 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.
|
||
|
||
### 7.4 Risks & mitigations
|
||
|
||
| Risk | Mitigation |
|
||
| --- | --- |
|
||
| Block-level selection feels coarse vs the editor's char-precise Edit Selection | Locked v1 decision (§6.7); a rendered surface is naturally block-grained; char-precise is a deferred follow-up if the coarseness bites |
|
||
| `data-src` attributes perturb markdown-it output or the F10 proposal/diff rendering | Wrapping is applied at the block boundary (outside inline parsing); covered by determinism + both-mode unit tests; per-block `try/catch` error chip (F7) on render failure |
|
||
| `diffToHunks` produces awkward hunk boundaries on a large rewrite | Pure + unit-tested over fixtures; hunks are line/block-aligned; worst case is more/fewer blocks, all independently ✓/✗-able — never wrong, just granular |
|
||
| Selection inside a deletion/proposal block has no live-source range | Falls back to Document scope by design (INV-36); manual-smoke verified |
|
||
| The webview selection→`data-src` lookup isn't E2E-testable (sealed) | The host half (offsets→fingerprint→propose) is E2E'd via simulated messages; the DOM lookup is the only manual-smoke-only seam, kept deliberately thin |
|
||
| Unhiding pin / adding `editDocument` widens the command surface | Both guard on `editorLangId == markdown`; both route through existing seams; no new model or persisted state |
|
||
|
||
---
|
||
|
||
## 8. Traceability matrix
|
||
|
||
| Requirement (`#43`) | Use case | Design | Slice |
|
||
| --- | --- | --- | --- |
|
||
| Pin baseline button in the preview toolbar | PUC-5 | INV-35, §6.4 (`DiffViewController.pin`) | SLICE-1 |
|
||
| Resolve the orphaned `pinDiffBaseline` reachability | PUC-5 | §6.4 (`when` flip) | SLICE-1 |
|
||
| Single adaptive Ask-Claude button (Selection ⇆ Document) | PUC-2/3/4 | INV-37, §6.2 | SLICE-3/4 |
|
||
| Preview-selection → source range mapping (block-level) | PUC-3 | INV-36, §6.7 | SLICE-2/4 |
|
||
| Edit Document path (new whole-document edit) | PUC-4 | INV-37, §6.5 (hunk diff) | SLICE-3 |
|
||
| Edits route through existing F4/F3 (no divergent path) | PUC-3/4 | INV-35, §6.4 | SLICE-3/4 |
|
||
| Right-click entry opens the preview (minimal gateway) | PUC-6 | §6.4 (`editor/title`) | SLICE-5 |
|
||
| Controls only active for supported (authorable) docs | PUC-1/7 | §6.5 | SLICE-5 |
|
||
| Sealed webview, no document mutation / LLM surface | — | INV-21/35, §6.6 | all |
|
||
| No new edit/attribution/proposal model | — | §1.7, INV-37 | all |
|
||
| Unit + host E2E + right-click-opens-preview coverage | — | §6.8 | SLICE-1..5 |
|
||
|
||
## 9. Open Questions & Decisions log
|
||
|
||
- **RESOLVED (session 0036, operator):** preview-selection → source mapping =
|
||
**block-level** (`data-src` attributes from `BlockWithRange`; union of
|
||
intersected blocks); document edit = **diffed into per-hunk F4 proposals**;
|
||
`#43` **lands a minimal right-click → Open Review Preview gateway** (`#41`/`#42`
|
||
expand the menus).
|
||
- **RESOLVED (this spec, autonomous):** instruction prompt = **host `showInputBox`**
|
||
(LLM/secrets stay out of the webview); Pin targets the **previewed document**
|
||
(`DiffViewController.pin`); **no confirmation** on pin (matches existing); a new
|
||
`cowriting.editDocument` command is registered for `#42` reuse; `pinDiffBaseline`
|
||
is unhidden (`editorLangId == markdown`).
|
||
- **OPEN → later:** **char-precise** sub-block selection mapping (deferred — block
|
||
granularity is v1); the **richer `#41`/`#42` menu sets** (this lands only the
|
||
minimal gateway); preview→source **scroll-sync** (`#32`); whether a large
|
||
document rewrite should cap/segment its hunks (only if real rewrites prove
|
||
noisy); the repo rename to `vscode-markdown-cowriting-plugin` (`#35`, deferred).
|
||
|
||
## 10. Glossary & References
|
||
|
||
- **Preview toolbar** — the review preview's header row: the annotations on/off
|
||
checkbox (existing) plus F11's Pin baseline and adaptive Ask-Claude buttons.
|
||
**Adaptive Ask-Claude button** — one button reading "Edit Selection" (non-empty
|
||
preview selection) or "Edit Document" (none). **Block-level selection mapping** —
|
||
resolving a rendered-preview selection to the union of source blocks it
|
||
intersects, via `data-src-start/end` attributes emitted by the pure render
|
||
layer. **Hunk-diffed document edit** — Claude's whole-document rewrite split
|
||
into changed hunks, each surfaced as its own F4 proposal. **Pin baseline** — F6's
|
||
`DiffViewController.pin` applied to the previewed document. **Gateway** — a
|
||
right-click entry that opens the preview (this feature lands the minimal one;
|
||
`#41`/`#42` expand them).
|
||
- Feature `#43` (F11) · Epic `#1` · builds on F3 `#6`, F4 `#12`, F6 `#17`/`#19`,
|
||
F7 `#21`/`#22`, F9 `#27`, F10 `#29`/`#31` · coexists with `#41`/`#42` · parent
|
||
specs `coauthoring-inner-loop.md`, `coauthoring-attribution.md`,
|
||
`coauthoring-propose-accept.md`, `coauthoring-diff-view.md`,
|
||
`coauthoring-rendered-preview.md`, `coauthoring-interactive-review.md` · capture
|
||
session 0035 · lineage `ben.stull/rfc-app#48`.
|