F11 — Preview toolbar as the primary interaction surface (#43) #44
@@ -14,8 +14,9 @@ catalog (a pure, key-free SDK call) in a notification and the
|
||||
Features shipped so far: F2 region-anchored threads (Feature #4), F3 live
|
||||
human/Claude attribution (Feature #6), F4 propose/accept diff flow
|
||||
(Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view
|
||||
toggle (Feature #17), and F10 interactive review — **write left / review
|
||||
right** (Feature #29).
|
||||
toggle (Feature #17), F10 interactive review — **write left / review
|
||||
right** (Feature #29), and F11 — the **preview toolbar as the primary
|
||||
interaction surface** (Feature #43).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -262,6 +263,33 @@ they are no longer separate user surfaces.
|
||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-interactive-review.md`.
|
||||
Live smoke: [`docs/MANUAL-SMOKE-F10.md`](docs/MANUAL-SMOKE-F10.md).
|
||||
|
||||
## F11 — Preview toolbar as the primary interaction surface (Feature #43)
|
||||
|
||||
The review preview's **header toolbar** becomes the cockpit for the inner loop.
|
||||
Beside the existing **Annotations** switch it gains two controls:
|
||||
|
||||
- **⌖ Pin baseline** — pins the previewed document's review baseline to now and
|
||||
clears the change-marks (homes the previously-orphaned `pinDiffBaseline`
|
||||
command, which is reachable from the palette again too).
|
||||
- **✦ Ask Claude…** — one **adaptive** button. Its label flips on the preview's
|
||||
selection: **Edit Selection** when text is selected in the rendered preview,
|
||||
**Edit Document** otherwise. Clicking it opens a host input box for the
|
||||
instruction (the LLM turn and prompt stay host-side — the sealed webview gains
|
||||
no LLM/credential surface), then surfaces the result as F4 proposals — **one**
|
||||
for a selection (mapped back to its source block-union), or **one per changed
|
||||
hunk** for a whole-document rewrite (`diffToHunks`), each independently ✓/✗-able.
|
||||
|
||||
The pure render layer emits `data-src-start`/`data-src-end` on every block in
|
||||
**both** modes (INV-36); the webview's only mapping duty is walking a selection
|
||||
to its nearest `data-src` ancestor. Right-clicking a markdown tab → **Open Review
|
||||
Preview** is the minimal gateway making the surface reachable end to end (#41/#42
|
||||
expand it). Edit controls are disabled on a non-authorable doc (reading stays
|
||||
allowed). No new model, no new persistence — pin via the F6 store, edits via the
|
||||
F4 propose/accept seam with F3 attribution (INV-35..37).
|
||||
|
||||
Design: [`docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md`](docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md).
|
||||
Live smoke: [`docs/MANUAL-SMOKE-F11.md`](docs/MANUAL-SMOKE-F11.md).
|
||||
|
||||
## Develop
|
||||
|
||||
- `npm run watch` — rebuild on change.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,6 +18,8 @@ interface RenderMessage {
|
||||
html: string;
|
||||
epoch?: string;
|
||||
summary?: { added: number; removed: number; proposals: number };
|
||||
/** F11: false on a non-authorable doc → Pin + Ask-Claude controls disabled. */
|
||||
authorable?: boolean;
|
||||
}
|
||||
|
||||
const vscodeApi = acquireVsCodeApi();
|
||||
@@ -26,12 +28,66 @@ 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;
|
||||
const askEl = document.getElementById("cw-ask") 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" });
|
||||
});
|
||||
|
||||
// F11 (SLICE-4): the single adaptive Ask-Claude button. Its label flips on
|
||||
// `selectionchange` (Edit Selection when live text is selected in the preview,
|
||||
// Edit Document otherwise), and a click resolves the selection to a SOURCE range
|
||||
// via the nearest `data-src` ancestors (INV-36) — the webview's sole mapping
|
||||
// duty. A selection that resolves to no live block falls back to document scope.
|
||||
|
||||
/** Walk up from a DOM node to the nearest block carrying data-src offsets (INV-36). */
|
||||
function nearestSrc(node: Node | null): HTMLElement | null {
|
||||
let el: HTMLElement | null = node instanceof HTMLElement ? node : (node?.parentElement ?? null);
|
||||
while (el && el !== body) {
|
||||
if (el.dataset.srcStart !== undefined && el.dataset.srcEnd !== undefined) return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The source [start,end) union of the live blocks a non-empty body selection touches, or null. */
|
||||
function selectionSrcRange(): { start: number; end: number } | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
|
||||
const ends = [nearestSrc(sel.anchorNode), nearestSrc(sel.focusNode)].filter(
|
||||
(e): e is HTMLElement => e !== null,
|
||||
);
|
||||
if (ends.length === 0) return null; // selection touches no live-source block
|
||||
const starts = ends.map((e) => Number(e.dataset.srcStart));
|
||||
const stops = ends.map((e) => Number(e.dataset.srcEnd));
|
||||
return { start: Math.min(...starts), end: Math.max(...stops) };
|
||||
}
|
||||
|
||||
function updateAskLabel(): void {
|
||||
if (!askEl) return;
|
||||
askEl.textContent = selectionSrcRange()
|
||||
? "✦ Ask Claude to Edit Selection"
|
||||
: "✦ Ask Claude to Edit Document";
|
||||
}
|
||||
|
||||
document.addEventListener("selectionchange", updateAskLabel);
|
||||
|
||||
askEl?.addEventListener("click", () => {
|
||||
const range = selectionSrcRange();
|
||||
if (range) {
|
||||
vscodeApi.postMessage({ type: "askClaude", scope: "selection", start: range.start, end: range.end });
|
||||
} else {
|
||||
vscodeApi.postMessage({ type: "askClaude", scope: "document" });
|
||||
}
|
||||
});
|
||||
|
||||
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
|
||||
body.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
|
||||
@@ -69,6 +125,11 @@ window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
|
||||
const msg = event.data;
|
||||
if (msg?.type !== "render") return;
|
||||
body.innerHTML = msg.html;
|
||||
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";
|
||||
if (annotationsEl) annotationsEl.checked = on;
|
||||
// Off-state is a clean preview: hide the review chrome.
|
||||
|
||||
+17
-1
@@ -78,6 +78,11 @@
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"title": "Cowriting: Open Review Preview",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"title": "Ask Claude to Edit Document",
|
||||
"category": "Cowriting"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -100,7 +105,18 @@
|
||||
},
|
||||
{
|
||||
"command": "cowriting.pinDiffBaseline",
|
||||
"when": "false"
|
||||
"when": "editorLangId == markdown"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"when": "editorLangId == markdown"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"when": "editorLangId == markdown",
|
||||
"group": "navigation@9"
|
||||
}
|
||||
],
|
||||
"editor/context": [
|
||||
|
||||
+116
-8
@@ -9,7 +9,7 @@
|
||||
* DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview.
|
||||
*/
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { diffArrays, diffWords } from "diff";
|
||||
import { diffArrays, diffWords, diffWordsWithSpace } from "diff";
|
||||
import { diffMermaid } from "./mermaidDiff";
|
||||
|
||||
export type BlockType = "prose" | "code" | "mermaid";
|
||||
@@ -193,6 +193,85 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[]
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** A contiguous changed region of `currentText` and its replacement (F11). */
|
||||
export interface EditHunk {
|
||||
/** char offset of the hunk's first changed char in currentText. */
|
||||
start: number;
|
||||
/** char offset one past the hunk's last changed char (start==end → a pure insertion). */
|
||||
end: number;
|
||||
/** the text that replaces currentText.slice(start, end). */
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* F11 (INV-37, §6.4): diff a whole-document rewrite into per-hunk replacement
|
||||
* ranges — each becomes one independent F4 single-range proposal (so a document
|
||||
* edit surfaces as N independently ✓/✗-able blue blocks, no new model). Pure,
|
||||
* vscode-free, deterministic. Offsets index into `currentText`: removed +
|
||||
* unchanged parts reconstruct it exactly, so advancing on those yields true
|
||||
* source offsets. Adjacent added/removed runs coalesce into one hunk; an
|
||||
* unchanged part flushes the open hunk.
|
||||
*
|
||||
* A PURE insertion (added text between two unchanged regions) would otherwise be
|
||||
* a zero-width hunk (start==end) — and F4's fingerprint of empty text can never
|
||||
* resolve (anchorer.resolve orphans an empty needle), so the proposal could
|
||||
* never be accepted. Every zero-width hunk is therefore anchored to an adjacent
|
||||
* source token (`anchorInsertion`): its range absorbs that token and its
|
||||
* replacement keeps it, so the net text is identical but `fp.text` is non-empty
|
||||
* and resolvable. A zero-width hunk is always bordered by unchanged text (or a
|
||||
* doc edge), so the absorbed token is genuinely present in `currentText` and is
|
||||
* never part of another hunk.
|
||||
*/
|
||||
export function diffToHunks(currentText: string, rewrittenText: string): EditHunk[] {
|
||||
const hunks: EditHunk[] = [];
|
||||
let offset = 0;
|
||||
let open: EditHunk | null = null;
|
||||
const flush = () => {
|
||||
if (open) hunks.push(open.start === open.end ? anchorInsertion(open, currentText) : open);
|
||||
open = null;
|
||||
};
|
||||
for (const part of diffWordsWithSpace(currentText, rewrittenText)) {
|
||||
if (part.added) {
|
||||
open ??= { start: offset, end: offset, replacement: "" };
|
||||
open.replacement += part.value;
|
||||
} else if (part.removed) {
|
||||
open ??= { start: offset, end: offset, replacement: "" };
|
||||
offset += part.value.length;
|
||||
open.end = offset;
|
||||
} else {
|
||||
flush();
|
||||
offset += part.value.length;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return hunks;
|
||||
}
|
||||
|
||||
const isWs = (c: string): boolean => /\s/.test(c);
|
||||
|
||||
/**
|
||||
* Grow a zero-width insertion hunk to absorb an adjacent source token so its F4
|
||||
* fingerprint has real text to resolve. Prefer the FOLLOWING token (optional
|
||||
* leading whitespace + a run of non-whitespace); at end-of-document, fall back to
|
||||
* the PRECEDING token. The absorbed text is kept in the replacement, so the
|
||||
* applied result is unchanged.
|
||||
*/
|
||||
function anchorInsertion(hunk: EditHunk, text: string): EditHunk {
|
||||
const p = hunk.start;
|
||||
if (p < text.length) {
|
||||
let e = p;
|
||||
while (e < text.length && isWs(text[e])) e++;
|
||||
while (e < text.length && !isWs(text[e])) e++;
|
||||
if (e === p) e = Math.min(text.length, p + 1);
|
||||
return { start: p, end: e, replacement: hunk.replacement + text.slice(p, e) };
|
||||
}
|
||||
let s = p;
|
||||
while (s > 0 && isWs(text[s - 1])) s--;
|
||||
while (s > 0 && !isWs(text[s - 1])) s--;
|
||||
if (s === p) s = Math.max(0, p - 1);
|
||||
return { start: s, end: p, replacement: text.slice(s, p) + hunk.replacement };
|
||||
}
|
||||
|
||||
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
||||
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
|
||||
// other fences fall through to markdown-it's default (escaped <pre><code>).
|
||||
@@ -247,7 +326,17 @@ function chip(message: string): string {
|
||||
return `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
|
||||
}
|
||||
|
||||
function renderOp(op: BlockOp, render: (src: string) => string): string {
|
||||
/**
|
||||
* F11 (INV-36): the source-range attributes a live block's wrapping element
|
||||
* carries, so a preview selection can map back to a source markdown range. `""`
|
||||
* when the block has no live source (a baseline-only deletion / a proposal
|
||||
* block) — those are skipped by the selection mapper. Pure + deterministic.
|
||||
*/
|
||||
function srcAttr(blk: { start: number; end: number } | undefined): string {
|
||||
return blk ? ` data-src-start="${blk.start}" data-src-end="${blk.end}"` : "";
|
||||
}
|
||||
|
||||
function renderOp(op: BlockOp, render: (src: string) => string, src = ""): string {
|
||||
const safe = (src: string): string => {
|
||||
try {
|
||||
return render(src);
|
||||
@@ -293,7 +382,7 @@ function renderOp(op: BlockOp, render: (src: string) => string): string {
|
||||
}
|
||||
break;
|
||||
}
|
||||
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
|
||||
return `<div class="cw-blk ${cls}"${src}>${badge}${inner}</div>`;
|
||||
}
|
||||
|
||||
export type AuthorKind = "claude" | "human";
|
||||
@@ -367,14 +456,31 @@ export function colorByAuthor(
|
||||
return sentinelsToSpans(render(injected));
|
||||
}
|
||||
|
||||
/** Off-state body: the current buffer as plain markdown, no annotations (INV-33). */
|
||||
/**
|
||||
* Off-state body: the current buffer as plain markdown, no annotations (INV-33).
|
||||
* Each block is wrapped in a bare `<div data-src-start/end>` so the off-mode
|
||||
* preview is still a selection→source mapping surface (INV-36) while staying
|
||||
* visually clean — no `cw-` annotation classes. A doc with no blocks (empty /
|
||||
* whitespace-only) renders whole. Pure + deterministic.
|
||||
*
|
||||
* Tradeoff of the locked block-level mapping (§6.7): rendering PER BLOCK (so each
|
||||
* carries its offsets) means a markdown construct split across blank-line-
|
||||
* separated blocks — a reference-link use and its definition — doesn't resolve
|
||||
* across blocks. `renderReview` already rendered per-block; this keeps the two
|
||||
* modes consistent rather than faithful-but-different. Characterized in tests.
|
||||
*/
|
||||
export function renderPlain(currentText: string, opts: RenderOptions = {}): string {
|
||||
const render = opts.render ?? defaultRender;
|
||||
const safe = (src: string): string => {
|
||||
try {
|
||||
return render(currentText);
|
||||
return render(src);
|
||||
} catch (err) {
|
||||
return chip(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
const blocks = splitBlocksWithRanges(currentText);
|
||||
if (blocks.length === 0) return safe(currentText);
|
||||
return blocks.map((b) => `<div${srcAttr(b)}>${safe(b.raw)}</div>`).join("\n");
|
||||
}
|
||||
|
||||
export interface ProposalView {
|
||||
@@ -411,11 +517,13 @@ function renderReviewOp(
|
||||
op: BlockOp,
|
||||
render: (src: string) => string,
|
||||
colored: (raw: string) => string,
|
||||
src: string,
|
||||
): string {
|
||||
// removed blocks and any changed block (atomic fences diffed whole; non-atomic prose
|
||||
// word-merged) render via renderOp — no author sentinels (deletions neutral, spec §6.7).
|
||||
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render);
|
||||
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}">${colored(op.block.raw)}</div>`;
|
||||
// `src` is "" for a removed block (no live source — INV-36).
|
||||
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
|
||||
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${colored(op.block.raw)}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,7 +579,7 @@ export function renderReview(
|
||||
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
||||
const colored = (raw: string): string =>
|
||||
blk ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
|
||||
bodyParts.push(renderReviewOp(op, render, colored));
|
||||
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
|
||||
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
||||
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
||||
}
|
||||
|
||||
+173
-14
@@ -13,11 +13,32 @@ import * as vscode from "vscode";
|
||||
import type { DiffViewController } from "./diffViewController";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import type { ProposalController } from "./proposalController";
|
||||
import { renderReview, renderPlain, diffBlocks, type BlockOp } from "./trackChangesModel";
|
||||
import { renderReview, renderPlain, diffBlocks, diffToHunks, type BlockOp } from "./trackChangesModel";
|
||||
import { buildFingerprint } from "./anchorer";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { EditTurnResult } from "./liveTurn";
|
||||
|
||||
/** F11: a host edit turn (selection/document text + instruction → rewrite). Injectable for tests. */
|
||||
type EditTurn = (instruction: string, text: string) => Promise<EditTurnResult>;
|
||||
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
|
||||
type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
|
||||
|
||||
const VIEW_TYPE = "cowriting.trackChangesPreview";
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
/**
|
||||
* 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" }
|
||||
| { type: "askClaude"; scope: "document" }
|
||||
| { type: "askClaude"; scope: "selection"; start: number; end: number };
|
||||
|
||||
export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly panels = new Map<string, vscode.WebviewPanel>();
|
||||
@@ -27,6 +48,19 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
private readonly mode = new Map<string, "on" | "off">();
|
||||
/** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */
|
||||
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88);
|
||||
/**
|
||||
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
|
||||
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
|
||||
*/
|
||||
private editTurn: EditTurn = async (instruction, text) => {
|
||||
const { runEditTurn } = await import("./liveTurn");
|
||||
return runEditTurn(instruction, text);
|
||||
};
|
||||
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
|
||||
private turnSeq = 0;
|
||||
private nextTurnSeq(): number {
|
||||
return ++this.turnSeq;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly diffView: DiffViewController,
|
||||
@@ -35,9 +69,22 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
private readonly proposals: ProposalController,
|
||||
) {
|
||||
this.disposables.push(
|
||||
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
|
||||
this.show(vscode.window.activeTextEditor?.document),
|
||||
),
|
||||
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
|
||||
// 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
|
||||
// active markdown doc; the rewrite is diffed into per-hunk F4 proposals.
|
||||
vscode.commands.registerCommand("cowriting.editDocument", () => {
|
||||
const doc = vscode.window.activeTextEditor?.document;
|
||||
if (!doc || !this.isMarkdown(doc)) {
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
|
||||
return;
|
||||
}
|
||||
void this.askClaude(doc, { kind: "document" });
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
|
||||
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
|
||||
this.proposals.onDidChangeProposals(({ uri }) => {
|
||||
@@ -91,9 +138,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 +169,82 @@ 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);
|
||||
} else if (m?.type === "askClaude") {
|
||||
const target: EditTarget =
|
||||
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
|
||||
void this.askClaude(document, target);
|
||||
}
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
}
|
||||
|
||||
/**
|
||||
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
|
||||
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
||||
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
|
||||
*/
|
||||
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
|
||||
const instruction = await vscode.window.showInputBox({
|
||||
prompt:
|
||||
target.kind === "document"
|
||||
? "What should Claude do with the document?"
|
||||
: "What should Claude do with the selection?",
|
||||
placeHolder: "e.g. tighten the prose",
|
||||
});
|
||||
if (!instruction) return;
|
||||
try {
|
||||
const ids = await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
|
||||
() => this.runEditAndPropose(document, target, instruction),
|
||||
);
|
||||
this.panels.set(key, panel);
|
||||
// A panel is now open for this doc — the off-panel indicator is redundant.
|
||||
this.hideStatus();
|
||||
this.refresh(document);
|
||||
if (ids.length === 0) {
|
||||
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
|
||||
} else {
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: Claude proposed ${ids.length} edit${ids.length === 1 ? "" : "s"} — review ✓/✗ in the preview.`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F11 (INV-35/37): run one host edit turn and record the result as F4
|
||||
* proposal(s) — a SELECTION yields one single-range proposal over the resolved
|
||||
* block-union; a DOCUMENT rewrite is `diffToHunks`'d into one single-range
|
||||
* proposal per changed hunk (reusing the F4 single-range model N times, no new
|
||||
* model). Never mutates the document (INV-10). Returns the created proposal ids.
|
||||
*/
|
||||
async runEditAndPropose(
|
||||
document: vscode.TextDocument,
|
||||
target: EditTarget,
|
||||
instruction: string,
|
||||
): Promise<string[]> {
|
||||
const full = document.getText();
|
||||
// One turnId per gesture — the document case's N hunk-proposals all share it,
|
||||
// so a single rewrite groups as one agent turn (parity with editSelection).
|
||||
const turnId = `turn-${this.nextTurnSeq()}`;
|
||||
const provenance = (turn: EditTurnResult) =>
|
||||
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
|
||||
if (target.kind === "range") {
|
||||
const selected = full.slice(target.start, target.end);
|
||||
const turn = await this.editTurn(instruction, selected);
|
||||
if (turn.replacement === "" || turn.replacement === selected) return [];
|
||||
const fp = buildFingerprint(full, { start: target.start, end: target.end });
|
||||
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
|
||||
return id ? [id] : [];
|
||||
}
|
||||
const turn = await this.editTurn(instruction, full);
|
||||
const ids: string[] = [];
|
||||
for (const h of diffToHunks(full, turn.replacement)) {
|
||||
const fp = buildFingerprint(full, { start: h.start, end: h.end });
|
||||
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), { turnId, instruction });
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private onEdit(document: vscode.TextDocument): void {
|
||||
@@ -145,8 +277,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
const baselineText = baseline?.text ?? current; // no baseline → no change-marks
|
||||
const ops = diffBlocks(baselineText, current);
|
||||
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") {
|
||||
void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current) });
|
||||
void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current), authorable });
|
||||
return;
|
||||
}
|
||||
const spans = this.attribution.spansFor(document);
|
||||
@@ -162,6 +296,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
html: renderReview(baselineText, current, spans, proposals),
|
||||
epoch: this.epochLabel(baseline),
|
||||
summary,
|
||||
authorable,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -232,6 +367,8 @@ 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>
|
||||
<button id="cw-ask" type="button" title="Ask Claude to edit (the selection if any, else the whole document)">✦ Ask Claude to Edit Document</button>
|
||||
<span id="cw-epoch">Review</span>
|
||||
<span id="cw-summary"></span>
|
||||
<span id="cw-legend"></span>
|
||||
@@ -246,6 +383,28 @@ 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);
|
||||
}
|
||||
/** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */
|
||||
setEditTurnForTest(fn: EditTurn): void {
|
||||
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 {
|
||||
return this.lastModel.get(uriString);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
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");
|
||||
});
|
||||
|
||||
// SLICE-3: Edit Document → a whole-document rewrite diffed into N F4 proposals.
|
||||
test("runEditAndPropose(document) with a stubbed multi-hunk rewrite → N proposals matching the hunks (PUC-4, INV-37)", async () => {
|
||||
const { doc, key } = await freshDoc(
|
||||
"docs/f11doc.md",
|
||||
"# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n",
|
||||
);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
// Stub the host edit turn (no LLM in CI): rewrite two distinct words.
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
replacement: "# F11 doc\n\nThe quick RED fox jumps over the lazy CAT.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f11-doc",
|
||||
}));
|
||||
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 2, "two changed words → two independent proposals");
|
||||
|
||||
const views = api.proposalController.listProposals(doc);
|
||||
assert.ok(
|
||||
ids.every((id) => views.some((v) => v.id === id)),
|
||||
"every returned proposal id is a live pending proposal",
|
||||
);
|
||||
const replacements = views.map((v) => v.replacement);
|
||||
assert.ok(replacements.includes("RED") && replacements.includes("CAT"), "proposals carry the per-hunk replacements");
|
||||
// INV-10: proposing never mutates the document.
|
||||
assert.ok(doc.getText().includes("brown fox") && doc.getText().includes("lazy dog"), "document unchanged by propose");
|
||||
void key;
|
||||
});
|
||||
|
||||
// SLICE-4: Edit Selection → one single-range proposal over the resolved block-union.
|
||||
test("runEditAndPropose(range) with a stubbed turn → exactly one proposal over the resolved range (PUC-3, INV-37)", async () => {
|
||||
const body = "# F11 sel\n\nThe target paragraph Claude will rewrite.\n\nAnother untouched paragraph.\n";
|
||||
const { doc, key } = await freshDoc("docs/f11sel.md", body);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
const target = "The target paragraph Claude will rewrite.";
|
||||
const start = doc.getText().indexOf(target);
|
||||
const end = start + target.length;
|
||||
ctl.setEditTurnForTest(async (_instruction, text) => {
|
||||
assert.strictEqual(text, target, "the turn receives exactly the selected source range");
|
||||
return { replacement: "The REWRITTEN paragraph from Claude.", model: "sonnet", sessionId: "e2e-f11-sel" };
|
||||
});
|
||||
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal");
|
||||
const views = api.proposalController.listProposals(doc);
|
||||
const view = views.find((v) => v.id === ids[0]);
|
||||
assert.ok(view, "the proposal is live");
|
||||
assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement");
|
||||
assert.strictEqual(view!.replaced, target, "replaces exactly the selected range");
|
||||
assert.ok(doc.getText().includes(target), "document unchanged by propose (INV-10)");
|
||||
void key;
|
||||
});
|
||||
|
||||
// SLICE-4: a no-op turn (Claude returns the input unchanged) produces no proposal.
|
||||
test("runEditAndPropose(range) where Claude returns the selection unchanged → no proposal", async () => {
|
||||
const { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const target = "Leave me exactly as I am.";
|
||||
const start = doc.getText().indexOf(target);
|
||||
ctl.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change");
|
||||
assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing");
|
||||
});
|
||||
|
||||
// SLICE-3 (review follow-up): a document rewrite that INSERTS text → an
|
||||
// acceptable proposal (not a born-orphaned zero-width hunk), and accepting all
|
||||
// hunks of a multi-hunk rewrite lands the intended document.
|
||||
test("document rewrite with an insertion → acceptable proposals; accept-all reaches the rewrite", async () => {
|
||||
const original = "# F11 accept\n\nThe brown fox sleeps.\n";
|
||||
const rewrite = "# F11 accept\n\nThe brown fox QUIETLY sleeps today.\n";
|
||||
const { doc } = await freshDoc("docs/f11accept.md", original);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "expand the sentence");
|
||||
await settle();
|
||||
assert.ok(ids.length >= 1, "the rewrite produced at least one proposal");
|
||||
|
||||
// Every proposal must be acceptable (the insertion-anchoring fix): accept each.
|
||||
for (const id of ids) {
|
||||
const ok = await api.proposalController.acceptById("docs/f11accept.md", id);
|
||||
assert.ok(ok, `proposal ${id} is acceptable (not born-orphaned)`);
|
||||
await settle();
|
||||
}
|
||||
assert.strictEqual(doc.getText(), rewrite, "accepting all hunks reconstructs the intended rewrite");
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
|
||||
});
|
||||
|
||||
// SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown.
|
||||
test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => {
|
||||
const all = await vscode.commands.getCommands(true);
|
||||
assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered");
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
|
||||
(m) => m.command === "cowriting.editDocument",
|
||||
);
|
||||
assert.ok(entry, "editDocument has a commandPalette entry");
|
||||
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, test, expect } from "vitest";
|
||||
import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
|
||||
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
|
||||
|
||||
describe("splitBlocks", () => {
|
||||
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
||||
@@ -319,3 +319,152 @@ describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {
|
||||
expect(html).not.toContain("classDef cwAdded");
|
||||
});
|
||||
});
|
||||
|
||||
// F11 SLICE-3 (INV-37, §6.4): a whole-document rewrite is diffed into per-hunk
|
||||
// proposal ranges — each an independent F4 single-range proposal. Pure,
|
||||
// vscode-free, deterministic; offsets index into currentText.
|
||||
describe("F11 diffToHunks (INV-37)", () => {
|
||||
test("an identical rewrite → zero hunks", () => {
|
||||
expect(diffToHunks("The same text.\n", "The same text.\n")).toEqual([]);
|
||||
});
|
||||
|
||||
test("a single changed word → one hunk over exactly that word", () => {
|
||||
const current = "The quick brown fox.";
|
||||
const hunks = diffToHunks(current, "The quick red fox.");
|
||||
expect(hunks).toHaveLength(1);
|
||||
expect(current.slice(hunks[0].start, hunks[0].end)).toBe("brown");
|
||||
expect(hunks[0].replacement).toBe("red");
|
||||
});
|
||||
|
||||
test("two disjoint changes → two hunks with correct ranges + replacements", () => {
|
||||
const current = "one two three four";
|
||||
const hunks = diffToHunks(current, "one TWO three FOUR");
|
||||
expect(hunks).toHaveLength(2);
|
||||
expect(current.slice(hunks[0].start, hunks[0].end)).toBe("two");
|
||||
expect(hunks[0].replacement).toBe("TWO");
|
||||
expect(current.slice(hunks[1].start, hunks[1].end)).toBe("four");
|
||||
expect(hunks[1].replacement).toBe("FOUR");
|
||||
// disjoint + ordered
|
||||
expect(hunks[0].end).toBeLessThanOrEqual(hunks[1].start);
|
||||
});
|
||||
|
||||
test("a wholesale replacement (nothing in common) → one full-range hunk", () => {
|
||||
const current = "alpha";
|
||||
const hunks = diffToHunks(current, "omega");
|
||||
expect(hunks).toHaveLength(1);
|
||||
expect(hunks[0]).toEqual({ start: 0, end: current.length, replacement: "omega" });
|
||||
});
|
||||
|
||||
test("is deterministic — same inputs → identical hunks", () => {
|
||||
const a = diffToHunks("a b c d", "a B c D");
|
||||
const b = diffToHunks("a b c d", "a B c D");
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
/** Apply hunks (right→left so earlier offsets stay valid) to reconstruct the rewrite. */
|
||||
const applyHunks = (current: string, hunks: ReturnType<typeof diffToHunks>): string => {
|
||||
let out = current;
|
||||
for (const h of [...hunks].sort((a, b) => b.start - a.start)) {
|
||||
out = out.slice(0, h.start) + h.replacement + out.slice(h.end);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
test("applying the hunks always reconstructs the rewrite (substitute / delete / insert / multi)", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["The quick brown fox.", "The quick red fox."],
|
||||
["one two three four", "one TWO three FOUR"],
|
||||
["alpha", "omega"],
|
||||
["keep this and drop that", "keep this"],
|
||||
["one two three", "one INSERTED two three"],
|
||||
["start middle end", "PREFIX start middle end SUFFIX"],
|
||||
["unchanged body", "unchanged body"],
|
||||
];
|
||||
for (const [current, rewrite] of cases) {
|
||||
expect(applyHunks(current, diffToHunks(current, rewrite))).toBe(rewrite);
|
||||
}
|
||||
});
|
||||
|
||||
test("an inserted run anchors to adjacent text — never a zero-width, unacceptable hunk (INV-37)", () => {
|
||||
// A pure insertion would otherwise produce start==end → an empty fingerprint
|
||||
// → resolve() orphans it → the proposal can never be accepted. Each hunk must
|
||||
// span real source text so its F4 fingerprint resolves.
|
||||
for (const [current, rewrite] of [
|
||||
["one two three", "one INSERTED two three"],
|
||||
["tail anchor", "tail anchor APPENDED"],
|
||||
["lead", "PREPENDED lead"],
|
||||
] as Array<[string, string]>) {
|
||||
for (const h of diffToHunks(current, rewrite)) {
|
||||
expect(h.end).toBeGreaterThan(h.start); // non-zero-width
|
||||
expect(current.slice(h.start, h.end).length).toBeGreaterThan(0); // real fp.text
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end
|
||||
// (source char offsets from BlockWithRange) on every LIVE-source rendered block,
|
||||
// in BOTH modes. The webview's selection→source mapping walks the DOM to the
|
||||
// nearest data-src ancestor; these offsets are the contract.
|
||||
describe("F11 data-src emission (INV-36)", () => {
|
||||
/** Pull every data-src-start/end pair from an HTML string, in document order. */
|
||||
const srcRanges = (html: string): Array<{ start: number; end: number }> =>
|
||||
Array.from(html.matchAll(/data-src-start="(\d+)" data-src-end="(\d+)"/g)).map((m) => ({
|
||||
start: Number(m[1]),
|
||||
end: Number(m[2]),
|
||||
}));
|
||||
|
||||
test("renderPlain wraps every block with data-src offsets equal to splitBlocksWithRanges (and stays clean)", () => {
|
||||
const doc = "# Title\n\nFirst para.\n\nSecond para.\n";
|
||||
const blocks = splitBlocksWithRanges(doc);
|
||||
const html = renderPlain(doc);
|
||||
expect(srcRanges(html)).toEqual(blocks.map((b) => ({ start: b.start, end: b.end })));
|
||||
// off-mode stays the clean preview: no annotation marks.
|
||||
expect(html).not.toContain("cw-");
|
||||
// each range slices back to its block's raw source.
|
||||
for (const b of blocks) expect(doc.slice(b.start, b.end)).toBe(b.raw);
|
||||
});
|
||||
|
||||
test("renderReview emits data-src on each live block; removed + proposal blocks carry none (INV-36)", () => {
|
||||
// baseline has an extra paragraph that is REMOVED in current; current adds one.
|
||||
const baseline = "Keep this.\n\nDrop this.\n";
|
||||
const current = "Keep this.\n\nBrand new.\n";
|
||||
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 4, replaced: "Keep", replacement: "Hold" }];
|
||||
const html = renderReview(baseline, current, [], proposals);
|
||||
const liveBlocks = splitBlocksWithRanges(current);
|
||||
// exactly one data-src per LIVE (current-side) block — removed/proposal blocks excluded.
|
||||
expect(srcRanges(html)).toEqual(liveBlocks.map((b) => ({ start: b.start, end: b.end })));
|
||||
// the proposal block itself is not a live-source block.
|
||||
const propIdx = html.indexOf('data-proposal-id="p1"');
|
||||
const propTag = html.slice(html.lastIndexOf("<div", propIdx), propIdx + 1);
|
||||
expect(propTag).not.toContain("data-src-start");
|
||||
});
|
||||
|
||||
test("data-src emission is deterministic — same inputs → identical HTML (extends INV-22)", () => {
|
||||
const a = renderPlain("alpha\n\nbeta\n");
|
||||
const b = renderPlain("alpha\n\nbeta\n");
|
||||
expect(a).toBe(b);
|
||||
const r1 = renderReview("x", "x\n\ny", [], []);
|
||||
const r2 = renderReview("x", "x\n\ny", [], []);
|
||||
expect(r1).toBe(r2);
|
||||
});
|
||||
|
||||
// CHARACTERIZATION (conscious tradeoff of the locked block-level mapping, §6.7
|
||||
// fork 1): both modes render markdown PER BLOCK so each block can carry its
|
||||
// data-src offsets. A consequence is that markdown constructs whose parts span
|
||||
// blank-line-separated blocks — a reference-link USE and its DEFINITION — do not
|
||||
// resolve across blocks (markdown-it sees each block in isolation). renderReview
|
||||
// already had this; F11 brings the off/clean preview into line with it (both
|
||||
// per-block) rather than leaving the two modes rendering differently. If
|
||||
// cross-block fidelity is wanted later, it is a follow-up (source-map driven
|
||||
// wrapping), not a change to the locked block-level decision.
|
||||
test("a reference-link definition in a separate block does not resolve (block-level rendering)", () => {
|
||||
const doc = "See [the spec][ref] for details.\n\n[ref]: https://example.com/spec\n";
|
||||
const html = renderPlain(doc);
|
||||
// the link is NOT resolved to an <a href> — the [text][ref] is rendered literally.
|
||||
expect(html).not.toContain('href="https://example.com/spec"');
|
||||
expect(html).toContain("[the spec][ref]");
|
||||
// both modes agree (renderReview is likewise per-block) — the consistency point.
|
||||
expect(renderReview(doc, doc, [], [])).not.toContain('href="https://example.com/spec"');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user