feat!: sunset the review-panel webview — built-in preview + native diff are the review surfaces (D3/D17, spec §6.10); Keep/Reject lens copy (PUC-2)

Deletes the last bespoke UI surface (TrackChangesPreviewController + its
sealed webview client assets); every entry point re-points at native VS
Code chrome per spec §5: the #41 right-click entries become
cowriting.openReviewPreview (enter coediting if needed -> "Open Preview to
the Side"), Ctrl+Alt+R/Cmd+Alt+R moves to cowriting.reviewChanges (native
diff), and the F12 CodeLens per-proposal titles read "Keep"/"Reject" with
a top-of-file "Keep all (N)"/"Reject all" pair once >=2 proposals are
pending. EditFlow drops its own askClaude/askEditInstruction (the
webview's only caller) and its now-unused constructor params.

Coverage that lived only in the webview's test seams (renderHtmlFor,
receiveMessage, isOpen, ...) moves to direct calls against the surviving
controllers/pure renderReview (test/e2e/suite/helpers.ts gains a shared
renderHtmlFor probe); a genuine gap (pending-proposal + unchanged-block
rendering) is backfilled in test/previewAnnotations.test.ts. README's "how
it works" is rewritten as the native-surface map; the superseded F6/F7/F9/
F10/F11 sections are kept as a marked historical record rather than
deleted outright.

292 unit + 91 E2E green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 14:51:18 -07:00
parent 17fc01e8d8
commit 2170a0d282
23 changed files with 412 additions and 1475 deletions
+89 -8
View File
@@ -14,9 +14,59 @@ catalog (a pure, key-free SDK call) in a notification and the
Features shipped so far: F2 region-anchored threads (Feature #4), F3 live Features shipped so far: F2 region-anchored threads (Feature #4), F3 live
human/Claude attribution (Feature #6), F4 propose/accept diff flow human/Claude attribution (Feature #6), F4 propose/accept diff flow
(Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view (Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view
toggle (Feature #17), F10 interactive review — **write left / review data layer (Feature #17), and the **native-surfaces migration** (the
right** (Feature #29), and F11 — the **preview toolbar as the primary "Coediting Markdown with a Machine" Solution Design) — the current UI, replacing
interaction surface** (Feature #43). the bespoke F7/F10/F11 review webview with VS Code's own chrome end to end. See
**How it works** below for the current experience; the F7/F9/F10/F11 sections
further down are kept as a **historical record** of the superseded webview (each
now says so up top).
## How it works
There is **no bespoke panel**. Every surface below is native VS Code chrome —
the editor title bar, status bar, Comments gutter, source control, diff editor,
and the built-in Markdown preview:
1. **Enter coediting.** Run **"✦ Coedit this Document with Claude"** (command
palette, editor title menu, or editor/explorer context menu) on a Markdown
document — it captures a **baseline** (a clean checkpoint: git HEAD if the
file is tracked and clean, otherwise a snapshot) to measure future changes
against. Until a document is entered, none of the surfaces below attach to
it. **"Stop editing with Claude"** reverses it.
2. **Title bar + status bar are the everyday entry points.** Once coedited, the
editor title bar carries **Review Changes**, **Ask Claude**, **Toggle
Annotations**, and (in snapshot mode) **Mark Changes as Reviewed**; a
`✦ Coediting · N changes` status-bar item shows the pending-change count and
opens the review when clicked — no need to find the Source Control pane.
3. **Ask Claude is comments-first.** **"Ask Claude"** (editor title, or select
text → right-click → "Ask Claude to Edit") opens a focused comment box on
the native **Comments API** — on your selection, or a top-anchored
whole-document thread with none selected. Claude replies in-thread and
offers an edit; accepting the offer turns it into one or more pending
proposals.
4. **Pending changes live in the buffer.** A proposal is **optimistically
applied** straight into the editor (green/blue insertion tint, a struck
deletion hint) with an inline **`✓ Keep` / `✗ Reject`** CodeLens above each
changed block — and, once ≥2 proposals are pending, a top-of-file
**`✓ Keep all (N)` / `✗ Reject all`** pair. Nothing is force-applied: a
proposal whose target text changed underneath it is skipped, never guessed.
5. **Native diff answers "what changed?"** **Review Changes** (editor title, or
`Ctrl+Alt+R` / `Cmd+Alt+R`) opens VS Code's own diff editor — baseline on the
left, your live document on the right — with the platform's inline/side-by-side
toggle, navigation, and accessibility for free. A Source Control gutter/viewlet
entry (quick-diff change bars, "Open Changes") is a bonus home for git users,
never the required path.
6. **The built-in Markdown preview is the annotated read.** "Open Preview to the
Side" (or right-click a markdown file/tab → **"Open Cowriting Review
Preview"**, `cowriting.openReviewPreview`) renders the document with
authorship/change coloring **inside VS Code's own preview** — green = human,
blue = Claude, strikethrough = deleted — toggled by **Toggle Annotations**
(command or the title-bar eye icon). No separate webview, no separate
persistence.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-native-surfaces.md`
(§5 UX Layout is the canonical source for this map). Migration plan:
[`docs/superpowers/plans/2026-07-01-native-surfaces-migration.md`](docs/superpowers/plans/2026-07-01-native-surfaces-migration.md).
## Architecture ## Architecture
@@ -134,7 +184,16 @@ record per the contract; git push/pull is the transport, no re-homing ever.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`. Design: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`.
## F6 — Diff-view toggle (Feature #17, #19) ## F6 — Diff-view toggle (Feature #17, #19) — SUPERSEDED
> **Superseded.** The two-pane `Ctrl+Alt+D` toggle this section describes was
> deleted in #34; only the baseline **data layer** survived, now serving the
> native diff editor (`Ctrl+Alt+R` / "Review Changes" — How it works §5) and the
> built-in preview annotations (§6). The native-surfaces migration's Task 2
> additionally **retired INV-18** (machine-landing auto-advance): a landed
> Claude edit stays a visible change until "Mark Changes as Reviewed" (the
> renamed "Pin Diff Baseline to Now"), it no longer auto-clears. Kept below as a
> historical record.
**`Ctrl+Alt+D`** (the same chord on macOS — not `Cmd`; or **Cowriting: Toggle **`Ctrl+Alt+D`** (the same chord on macOS — not `Cmd`; or **Cowriting: Toggle
Diff View**) flips the focused document into a native `vscode.diff` against a Diff View**) flips the focused document into a native `vscode.diff` against a
@@ -166,7 +225,12 @@ instead of git archaeology.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-diff-view.md`. Design: `vscode-cowriting-plugin-content/specs/coauthoring-diff-view.md`.
Live smoke: [`docs/MANUAL-SMOKE-F6.md`](docs/MANUAL-SMOKE-F6.md). Live smoke: [`docs/MANUAL-SMOKE-F6.md`](docs/MANUAL-SMOKE-F6.md).
## F7 — Rendered track-changes preview (Feature #21) ## F7 — Rendered track-changes preview (Feature #21) — SUPERSEDED
> **Superseded (Task 8, native-surfaces migration).** The bespoke webview this
> section describes was deleted; its authorship/change coloring lives on
> **inside VS Code's own built-in Markdown preview** — see **How it works** §6
> above. Kept below as a historical record of the pre-migration design.
**`Ctrl+Alt+R`** (or **Cowriting: Open Track-Changes Preview**) opens a **`Ctrl+Alt+R`** (or **Cowriting: Open Track-Changes Preview**) opens a
read-only webview **beside** a **Markdown** editor that renders the document and read-only webview **beside** a **Markdown** editor that renders the document and
@@ -198,7 +262,12 @@ the manual smoke, not the sealed-sandbox E2E.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`. Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`.
Live smoke: [`docs/MANUAL-SMOKE-F7.md`](docs/MANUAL-SMOKE-F7.md). Live smoke: [`docs/MANUAL-SMOKE-F7.md`](docs/MANUAL-SMOKE-F7.md).
## F9 — Authorship view in the preview (Feature ~#27) ## F9 — Authorship view in the preview (Feature ~#27) — SUPERSEDED
> **Superseded (Task 8, native-surfaces migration).** The F7 webview this mode
> lived in was deleted; the built-in preview's annotations (How it works §6)
> always show authorship + change coloring together — there is no separate
> mode toggle. Kept below as a historical record.
The rendered preview (F7) gains a second mode, switched by a `[ Track changes | The rendered preview (F7) gains a second mode, switched by a `[ Track changes |
Authorship ]` toggle in its header. **Authorship** mode re-renders the current Authorship ]` toggle in its header. **Authorship** mode re-renders the current
@@ -239,7 +308,13 @@ a plain PR revert with zero data migration.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-out-of-workspace.md`. Design: `vscode-cowriting-plugin-content/specs/coauthoring-out-of-workspace.md`.
Live smoke: [`docs/MANUAL-SMOKE-F8.md`](docs/MANUAL-SMOKE-F8.md). Live smoke: [`docs/MANUAL-SMOKE-F8.md`](docs/MANUAL-SMOKE-F8.md).
## F10 — Interactive review: write left / review right (Feature #29) ## F10 — Interactive review: write left / review right (Feature #29) — SUPERSEDED
> **Superseded (Task 8, native-surfaces migration).** The editor is no longer
> "zero-annotation" — pending proposals now render optimistically applied
> in-buffer with `✓ Keep`/`✗ Reject` CodeLens (F12), and review happens across
> the native diff editor + built-in preview, not one dedicated webview. See
> **How it works** above. Kept below as a historical record.
A clean, **zero-annotation editor** on the left; the rendered preview on the A clean, **zero-annotation editor** on the left; the rendered preview on the
right as the **single interactive review surface**. The editor carries no right as the **single interactive review surface**. The editor carries no
@@ -266,7 +341,13 @@ they are no longer separate user surfaces.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-interactive-review.md`. Design: `vscode-cowriting-plugin-content/specs/coauthoring-interactive-review.md`.
Live smoke: [`docs/MANUAL-SMOKE-F10.md`](docs/MANUAL-SMOKE-F10.md). Live smoke: [`docs/MANUAL-SMOKE-F10.md`](docs/MANUAL-SMOKE-F10.md).
## F11 — Preview toolbar as the primary interaction surface (Feature #43) ## F11 — Preview toolbar as the primary interaction surface (Feature #43) — SUPERSEDED
> **Superseded (Task 8, native-surfaces migration).** The webview toolbar this
> section describes was deleted; its two controls moved to native chrome —
> **Pin baseline** is now **"Mark Changes as Reviewed"** in the editor title
> bar, and **Ask Claude** is the comments-first ask (How it works §3). Kept
> below as a historical record.
The review preview's **header toolbar** becomes the cockpit for the inner loop. The review preview's **header toolbar** becomes the cockpit for the inner loop.
Beside the existing **Annotations** switch it gains two controls: Beside the existing **Annotations** switch it gains two controls:
+5 -21
View File
@@ -30,29 +30,15 @@ const liveTurnOptions = {
logLevel: "info", logLevel: "info",
}; };
/** @type {import('esbuild').BuildOptions} */
const previewOptions = {
entryPoints: ["media/preview.ts"],
outfile: "out/media/preview.js",
bundle: true,
// The webview is a browser context; mermaid is bundled IN (and ONLY in) this
// asset so it never bloats the extension-host bundle (the @cline/sdk size
// discipline). No externals — everything is shipped to the sealed webview.
platform: "browser",
format: "iife",
target: "es2020",
sourcemap: true,
logLevel: "info",
};
/** @type {import('esbuild').BuildOptions} */ /** @type {import('esbuild').BuildOptions} */
const previewMermaidOptions = { const previewMermaidOptions = {
entryPoints: ["media/preview-mermaid.ts"], entryPoints: ["media/preview-mermaid.ts"],
outfile: "out/media/preview-mermaid.js", outfile: "out/media/preview-mermaid.js",
bundle: true, bundle: true,
// Task 7 (Q4): contributed via markdown.previewScripts into the BUILT-IN // Task 7 (Q4): contributed via markdown.previewScripts into the BUILT-IN
// preview's webview — same browser/IIFE shape as previewOptions above, and // preview's webview (a browser context) — mermaid is bundled IN (and ONLY
// the same reason mermaid is bundled here (never in the host bundle). // in) this asset so it never bloats the extension-host bundle (the
// @cline/sdk size discipline).
platform: "browser", platform: "browser",
format: "iife", format: "iife",
target: "es2020", target: "es2020",
@@ -63,16 +49,14 @@ const previewMermaidOptions = {
if (watch) { if (watch) {
const ctx = await context(options); const ctx = await context(options);
const ctxLive = await context(liveTurnOptions); const ctxLive = await context(liveTurnOptions);
const ctxPreview = await context(previewOptions);
const ctxPreviewMermaid = await context(previewMermaidOptions); const ctxPreviewMermaid = await context(previewMermaidOptions);
await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch(), ctxPreviewMermaid.watch()]); await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreviewMermaid.watch()]);
console.log("esbuild: watching…"); console.log("esbuild: watching…");
} else { } else {
await build(options); await build(options);
await build(liveTurnOptions); await build(liveTurnOptions);
await build(previewOptions);
await build(previewMermaidOptions); await build(previewMermaidOptions);
console.log( console.log(
"esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js + out/media/preview-mermaid.js", "esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview-mermaid.js",
); );
} }
-118
View File
@@ -1,118 +0,0 @@
/* F7 track-changes preview — theme-aware via VS Code webview CSS variables. */
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
padding: 0 1.2rem 2rem;
line-height: 1.5;
}
#cw-header {
position: sticky;
top: 0;
background: var(--vscode-editor-background);
border-bottom: 1px solid var(--vscode-panel-border);
padding: 0.5rem 0;
font-size: 0.85em;
opacity: 0.85;
display: flex;
gap: 1rem;
}
/* Author-colored track changes — style = operation, color = author.
underline = inserted, strikethrough = removed; human green/red, Claude blue/purple. */
#cw-summary .cw-add { color: #3fb950; }
#cw-summary .cw-del { color: #f85149; }
.cw-blk { position: relative; }
/* base ins/del carry the operation; author classes carry the color */
ins { text-decoration: none; }
del { text-decoration: line-through; opacity: 0.7; }
.cw-ins-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
.cw-ins-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
.cw-ins-none { background: var(--vscode-diffEditor-insertedTextBackground, rgba(63,185,80,0.14)); text-decoration: none; }
.cw-del-human { background: rgba(248,81,73,0.11); text-decoration: line-through; text-decoration-color: #f85149; }
.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; }
.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; }
/* Task 8 — overlap: if a future render path nests cw-ins-{A} inside cw-del-{B},
both marks must show. cw-ins-* sets text-decoration:none which would otherwise
suppress the parent del's strikethrough. `inherit` restores the parent's
line-through while the child's border-bottom underline is unaffected.
The engine currently emits SIBLING ins/del (never nested) so this rule is a
forward defensive guarantee only. See task-8-report.md for details. */
.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude { text-decoration: inherit; }
/* whole-block ops: an added block is an insertion (its author runs are emitted as
cw-ins-* via colorByAuthor with kind="ins"); a standalone removed block is neutral
struck (adjacency heuristic fallback) */
.cw-added { background: transparent; }
.cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.10)); text-decoration: line-through; opacity: 0.65; }
.cw-changed { outline: none; }
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
#cw-summary .cw-prop { opacity: 0.85; }
.cw-badge {
position: absolute;
top: 0;
right: 0;
font-size: 0.7em;
padding: 0 0.4em;
border-radius: 3px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
}
.cw-error {
border: 1px solid var(--vscode-inputValidation-errorBorder);
background: var(--vscode-inputValidation-errorBackground);
color: var(--vscode-errorForeground);
padding: 0.3rem 0.6rem;
border-radius: 3px;
font-size: 0.85em;
}
pre.mermaid { text-align: center; background: transparent; }
pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
/* 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 {
position: relative;
border-left: 3px solid var(--vscode-panel-border, #555);
background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent);
padding: 0.4em 0.6em; margin: 0.4em 0; border-radius: 3px;
}
.cw-proposal-unanchored { border-left-style: dashed; opacity: 0.85; }
.cw-actions { position: absolute; top: 0.2em; right: 0.4em; display: inline-flex; gap: 0.25em; }
.cw-actions button {
cursor: pointer; border: 1px solid var(--vscode-button-border, transparent);
border-radius: 3px; font-size: 0.9em; line-height: 1; padding: 0.1em 0.35em;
background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground);
}
.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
.cw-btngroup { display: inline-flex; }
.cw-btngroup .cw-caret { border-left: none; padding: 0.1em 0.25em; }
.cw-actions .cw-accept { font-weight: 600; }
.cw-accept:hover, .cw-btngroup:has(.cw-accept) .cw-caret:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover, .cw-btngroup:has(.cw-reject) .cw-caret:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
/* F7.1 (#22) intra-diagram mermaid diff legend. */
.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; }
.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; }
.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; }
.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; }
-157
View File
@@ -1,157 +0,0 @@
/**
* F7 preview webview client (sealed sandbox, INV-21). Receives annotated HTML
* from the extension host and swaps it in; runs mermaid over `.mermaid` blocks
* (mermaid needs a DOM, so it runs here, not in the host). Bundled by esbuild as
* a standalone IIFE → out/media/preview.js, so mermaid never enters the host
* bundle. No network, no LLM.
*/
import mermaid from "mermaid";
// Imported so esbuild emits the sibling out/media/preview.css (the controller
// links it into the sealed shell via asWebviewUri).
import "./preview.css";
declare function acquireVsCodeApi(): { postMessage(m: unknown): void };
interface RenderMessage {
type: "render";
mode: "on" | "off";
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();
const body = document.getElementById("cw-body")!;
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;
const acceptAllEl = document.getElementById("cw-acceptall") 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" });
}
});
// #46 (INV-42): Accept all — batch-accept every pending proposal (intent only).
acceptAllEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "acceptAll" });
});
// F12/#64: Accept/Reject (+ caret → accept-all/reject-all) on a proposal block.
body.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
if (!btn) return;
const id = btn.closest<HTMLElement>(".cw-proposal")?.dataset.proposalId;
const action = btn.dataset.action;
if (action === "acceptAll") return void vscodeApi.postMessage({ type: "acceptAll" });
if (action === "rejectAll") return void vscodeApi.postMessage({ type: "rejectAll" });
if (id && (action === "accept" || action === "reject")) {
vscodeApi.postMessage({ type: action, proposalId: id });
}
});
function themeFor(): "dark" | "default" {
return document.body.classList.contains("vscode-dark") ||
document.body.classList.contains("vscode-high-contrast")
? "dark"
: "default";
}
async function renderMermaid(): Promise<void> {
const nodes = Array.from(body.querySelectorAll<HTMLElement>("pre.mermaid"));
if (nodes.length === 0) return;
mermaid.initialize({ startOnLoad: false, theme: themeFor(), securityLevel: "strict" });
try {
await mermaid.run({ nodes });
} catch {
// mermaid.run already marks failed nodes; ensure a visible chip per failure.
for (const n of nodes) {
if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true");
}
}
}
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;
// #46: Accept all shows only with ≥2 pending proposals, on an authorable doc,
// in the annotated (on) state — a single proposal is just a ✓ in place.
if (acceptAllEl) acceptAllEl.hidden = !on || !authorable || (msg.summary?.proposals ?? 0) < 2;
// Off-state is a clean preview: hide the review chrome.
header.hidden = !on;
summary.hidden = !on;
legend.hidden = true;
if (on) {
header.textContent = `Review since ${msg.epoch ?? ""}`;
summary.innerHTML =
`<span class="cw-add">+${msg.summary?.added ?? 0}</span> ` +
`<span class="cw-del">${msg.summary?.removed ?? 0}</span> ` +
`<span class="cw-prop">${msg.summary?.proposals ?? 0} proposal${(msg.summary?.proposals ?? 0) === 1 ? "" : "s"}</span>`;
}
void renderMermaid();
});
+7 -7
View File
@@ -109,8 +109,8 @@
"icon": "$(git-compare)" "icon": "$(git-compare)"
}, },
{ {
"command": "cowriting.showTrackChangesPreview", "command": "cowriting.openReviewPreview",
"title": "Open Cowriting Review Panel", "title": "Open Cowriting Review Preview",
"category": "Cowriting" "category": "Cowriting"
}, },
{ {
@@ -252,7 +252,7 @@
"group": "navigation@4" "group": "navigation@4"
}, },
{ {
"command": "cowriting.showTrackChangesPreview", "command": "cowriting.openReviewPreview",
"when": "editorLangId == markdown", "when": "editorLangId == markdown",
"group": "navigation@9" "group": "navigation@9"
} }
@@ -264,14 +264,14 @@
"group": "1_cowriting@1" "group": "1_cowriting@1"
}, },
{ {
"command": "cowriting.showTrackChangesPreview", "command": "cowriting.openReviewPreview",
"when": "resourceLangId == markdown", "when": "resourceLangId == markdown",
"group": "1_cowriting@3" "group": "1_cowriting@3"
} }
], ],
"explorer/context": [ "explorer/context": [
{ {
"command": "cowriting.showTrackChangesPreview", "command": "cowriting.openReviewPreview",
"when": "resourceLangId == markdown", "when": "resourceLangId == markdown",
"group": "navigation@9" "group": "navigation@9"
} }
@@ -330,10 +330,10 @@
}, },
"keybindings": [ "keybindings": [
{ {
"command": "cowriting.showTrackChangesPreview", "command": "cowriting.reviewChanges",
"key": "ctrl+alt+r", "key": "ctrl+alt+r",
"mac": "cmd+alt+r", "mac": "cmd+alt+r",
"when": "editorLangId == markdown" "when": "editorLangId == markdown && cowriting.isCoediting"
}, },
{ {
"command": "cowriting.edit", "command": "cowriting.edit",
+10 -103
View File
@@ -1,28 +1,22 @@
/** /**
* EditFlow — the host edit-turn flow (F11/F12), extracted from the review * EditFlow — the host edit-turn flow (F11/F12), extracted from the review
* webview controller ahead of its sunset (native-surfaces migration, spec * webview controller ahead of its sunset (native-surfaces migration, spec
* §6.10). Owns the instruction prompt + progress-wrapped "ask Claude" gesture * §6.10). Owns the low-level turn→proposal(s) cut (`runEditAndPropose`,
* (INV-10 gate, INV-8 host-only LLM surface), and the low-level turn→proposal(s) * INV-39/40): never mutates the document directly — every turn lands as one or
* cut (`runEditAndPropose`, INV-39/40). Never mutates the document directly — * more F4 proposals via `ProposalController.propose` (INV-10). Reachable, from
* every turn lands as one or more F4 proposals via `ProposalController.propose` * Task 6 on, from the thread controller (`ThreadController.runMakeEdit`) —
* (INV-10). `runEditAndPropose` is reachable from the review webview (via this * vscode-API-only, no webview state. The `cowriting.editDocument` command lives
* class's `askClaude`) and, from Task 6 on, the thread controller * in extension.ts (Finding-1 fix) so it can hand off to
* (`ThreadController.runMakeEdit`) — vscode-API-only, no webview state. The * `ThreadController.askClaude()`. Task 8 deleted this class's own
* `cowriting.editDocument` command lives in extension.ts (Finding-1 fix) so it * `askClaude`/`askEditInstruction` (the review webview's toolbar ask) once
* can hand off to `ThreadController.askClaude()`. * their only caller — the webview — was sunset alongside them.
*/ */
import * as vscode from "vscode"; import * as vscode from "vscode";
import type { ProposalController } from "./proposalController"; import type { ProposalController } from "./proposalController";
import type { AttributionController } from "./attributionController";
import type { LiveProgressUi } from "./liveProgressUi";
import type { CoeditingRegistry } from "./coeditingRegistry";
import { diffToBlockHunks } from "./trackChangesModel"; import { diffToBlockHunks } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer"; import { buildFingerprint } from "./anchorer";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
/** The exact warning copy for every gated edit gesture (INV-10). */
const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first.";
/** /**
* F11: a host edit turn (selection/document text + instruction → rewrite). * F11: a host edit turn (selection/document text + instruction → rewrite).
* Injectable for tests. #60: accepts optional turn options (onProgress/signal); * Injectable for tests. #60: accepts optional turn options (onProgress/signal);
@@ -42,100 +36,13 @@ export class EditFlow implements vscode.Disposable {
const { runEditTurn } = await import("./liveTurn"); const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text, opts); return runEditTurn(instruction, text, opts);
}; };
/**
* LEGACY (Task 6, spec §6.10): the instruction prompt used to be the
* multi-line split-below input webview (`editInstructionInput.ts`, deleted).
* The real interactive ask is now ThreadController.askClaude — a focused
* comment box (D19); `cowriting.editSelection` routes there directly. This
* field's only remaining production caller is the `cowriting.editDocument`
* command below (via `askClaude`), and the review-webview's toolbar ask —
* both die fully in Task 8. Rejects by default so any surviving real call
* fails loudly instead of silently invoking a webview that no longer
* exists; host E2E stub this field directly (mirrors `editTurn`).
*/
askEditInstruction: (header: string) => Promise<string | undefined> = (header) =>
Promise.reject(
new Error(`Cowriting: the "${header}" instruction input was removed — use ✦ Ask Claude instead.`),
);
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0; private turnSeq = 0;
private nextTurnSeq(): number { private nextTurnSeq(): number {
return ++this.turnSeq; return ++this.turnSeq;
} }
constructor( constructor(private readonly proposals: ProposalController) {}
private readonly proposals: ProposalController,
private readonly attribution: AttributionController,
private readonly liveProgressUi: LiveProgressUi,
private readonly registry: CoeditingRegistry,
) {
// Code-review fix (Finding 1, session native-surfaces-exec): the
// `cowriting.editDocument` command used to be registered here, calling
// `this.askClaude(doc, {kind:"document"})` — which prompts via the (now
// rejecting-stub) `askEditInstruction`, a silent dead end. That command is now
// registered in extension.ts, AFTER ThreadController exists, and hands off to
// `ThreadController.askClaude()` (the comments-first ask, D19) instead. This
// class's `askClaude`/`askEditInstruction` remain: the review webview's
// toolbar ask (`trackChangesPreview.ts`) still delegates to them, slated for
// deletion alongside that webview in Task 8.
}
/**
* 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`. As of the
* Finding-1 fix, its only production caller is the review webview's `askClaude`
* toolbar intent (either scope) — `cowriting.editDocument`/`cowriting.editSelection`
* now delegate to `ThreadController.askClaude()` instead (D19). Both die together
* in Task 8 when the webview is sunset.
*/
async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
// INV-10: the choke point for the webview's askClaude toolbar intent (either
// scope) — a non-entered doc gets the warning, not a turn.
if (!this.registry.isCoediting(document.uri)) {
void vscode.window.showWarningMessage(NOT_COEDITING_WARNING);
return;
}
// Both scopes use the same multi-line split-below webview box; only the header
// (and the downstream proposal logic) differs. For a selection the document
// above keeps the selection highlighted while the box is open.
const header =
target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection";
const instruction = await this.askEditInstruction(header);
if (!instruction) return;
try {
const ids = await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const ui = this.liveProgressUi.begin(instruction, progress, token);
try {
return await this.runEditAndPropose(document, target, instruction, {
onProgress: ui.onProgress,
signal: ui.signal,
});
} catch (err) {
// #60 (INV-47): a user cancel proposes nothing (the benign empty path).
if (token.isCancellationRequested) return [] as string[];
throw err;
}
},
);
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/F12 (INV-35/39): run one host edit turn and record the result as F4 * F11/F12 (INV-35/39): run one host edit turn and record the result as F4
+20 -6
View File
@@ -216,19 +216,33 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
} }
} }
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */ /**
* CodeLensProvider (Task 8, PUC-2 wording): a `✓ Keep` / `✗ Reject` pair above
* each applied block, plus — when ≥2 proposals are pending on the doc — a
* top-of-file `✓ Keep all (N)` / `✗ Reject all` pair routed directly at the
* batch commands (no QuickPick detour for the common "review is done" case).
*/
provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
if (document.languageId !== "markdown") return []; if (document.languageId !== "markdown") return [];
if (!this.registry.isCoediting(document.uri)) return []; if (!this.registry.isCoediting(document.uri)) return [];
const key = this.proposals.keyFor(document); const key = this.proposals.keyFor(document);
const applied = this.proposals
.listProposals(document)
.filter((v) => v.anchorStart !== null && this.proposals.isApplied(key, v.id));
const lenses: vscode.CodeLens[] = []; const lenses: vscode.CodeLens[] = [];
for (const v of this.proposals.listProposals(document)) { if (applied.length >= 2) {
if (v.anchorStart === null || !this.proposals.isApplied(key, v.id)) continue; const top = new vscode.Range(0, 0, 0, 0);
const pos = document.positionAt(v.anchorStart); lenses.push(
new vscode.CodeLens(top, { title: `✓ Keep all (${applied.length})`, command: "cowriting.acceptAllProposals" }),
new vscode.CodeLens(top, { title: "✗ Reject all", command: "cowriting.rejectAllProposals" }),
);
}
for (const v of applied) {
const pos = document.positionAt(v.anchorStart!);
const line = new vscode.Range(pos.line, 0, pos.line, 0); const line = new vscode.Range(pos.line, 0, pos.line, 0);
lenses.push( lenses.push(
new vscode.CodeLens(line, { title: "Accept ▾", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }), new vscode.CodeLens(line, { title: "✓ Keep", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }),
new vscode.CodeLens(line, { title: "Reject", command: "cowriting.proposalRejectMenu", arguments: [v.id] }), new vscode.CodeLens(line, { title: "Reject", command: "cowriting.proposalRejectMenu", arguments: [v.id] }),
); );
} }
return lenses; return lenses;
+43 -34
View File
@@ -11,7 +11,6 @@ import { GlobalSidecarStore } from "./globalSidecarStore";
import { SidecarRouter } from "./sidecarRouter"; import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController"; import { DiffViewController } from "./diffViewController";
import { GitBaselineAdapter } from "./gitBaseline"; import { GitBaselineAdapter } from "./gitBaseline";
import { TrackChangesPreviewController } from "./trackChangesPreview";
import { EditFlow } from "./editFlow"; import { EditFlow } from "./editFlow";
import { LiveProgressUi } from "./liveProgressUi"; import { LiveProgressUi } from "./liveProgressUi";
import { EditorProposalController } from "./editorProposalController"; import { EditorProposalController } from "./editorProposalController";
@@ -29,7 +28,6 @@ export interface CowritingApi {
proposalController: ProposalController; proposalController: ProposalController;
versionGuard: VersionGuard; versionGuard: VersionGuard;
diffViewController: DiffViewController; diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController;
editFlow: EditFlow; editFlow: EditFlow;
sidecarRouter: SidecarRouter; sidecarRouter: SidecarRouter;
liveProgressUi: LiveProgressUi; liveProgressUi: LiveProgressUi;
@@ -160,8 +158,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry); const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry);
context.subscriptions.push(attributionController); context.subscriptions.push(attributionController);
// --- F4: propose/accept (Feature #12) — constructed before the preview so F10 // --- F4: propose/accept (Feature #12) ---
// can route ✓/✗ through it ---
const proposalController = new ProposalController( const proposalController = new ProposalController(
sidecarRouter, sidecarRouter,
attributionController, attributionController,
@@ -174,10 +171,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
// --- F11/F12 (native-surfaces migration, spec §6.10): the edit flow — the // --- F11/F12 (native-surfaces migration, spec §6.10): the edit flow — the
// `cowriting.editDocument` command, the instruction-prompt/progress-wrapped // `cowriting.editDocument` command, the instruction-prompt/progress-wrapped
// "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut // "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut
// (`runEditAndPropose`, INV-39/40). Constructed BEFORE the review preview (which // (`runEditAndPropose`, INV-39/40). Constructed BEFORE the thread controller,
// delegates to it) and BEFORE the thread controller, which now consumes it too // which consumes it too (Task 6: the comment-loop's "make this edit" offer
// (Task 6: the comment-loop's "make this edit" offer calls runEditAndPropose). --- // calls runEditAndPropose). ---
const editFlow = new EditFlow(proposalController, attributionController, liveProgressUi, coeditingRegistry); const editFlow = new EditFlow(proposalController);
context.subscriptions.push(editFlow); context.subscriptions.push(editFlow);
// --- F2/F10 (Task 6, D19/D10/D8): comments-first ask + the comment→reply→ // --- F2/F10 (Task 6, D19/D10/D8): comments-first ask + the comment→reply→
@@ -186,20 +183,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry, editFlow, liveProgressUi); const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry, editFlow, liveProgressUi);
context.subscriptions.push(threadController); context.subscriptions.push(threadController);
// --- F7/F10: the review preview is the single interactive review surface ---
// Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline,
// INV-20). Constructed AFTER attribution (reads F3 spans), proposals (routes
// F4 accept/reject from the webview ✓/✗), and editFlow (delegates the webview's
// askClaude toolbar intent to it).
const trackChangesPreviewController = new TrackChangesPreviewController(
diffViewController,
context.extensionUri,
attributionController,
proposalController,
editFlow,
);
context.subscriptions.push(trackChangesPreviewController);
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer, // --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). --- // decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
const editorProposalController = new EditorProposalController( const editorProposalController = new EditorProposalController(
@@ -211,10 +194,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
context.subscriptions.push(editorProposalController); context.subscriptions.push(editorProposalController);
// #46 (INV-42): accept every pending proposal on the active doc in one gesture // #46 (INV-42): accept every pending proposal on the active doc in one gesture
// (also reachable from the preview toolbar's "Accept all" button, which routes // (also reachable from the top-of-file "Keep all (N)" CodeLens, Task 8 PUC-2).
// through its own webview-facing acceptAll). Reuses the batched F4 seam + // Reuses the batched F4 seam + reports applied-vs-skipped, routing directly to
// reports applied-vs-skipped (native-surfaces migration: routes directly to // ProposalController (native-surfaces migration: no preview-controller
// ProposalController, no preview-controller indirection). // indirection).
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => { vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => {
const doc = vscode.window.activeTextEditor?.document; const doc = vscode.window.activeTextEditor?.document;
@@ -223,7 +206,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
return; return;
} }
const { applied, skipped } = await proposalController.acceptAllProposals(doc); const { applied, skipped } = await proposalController.acceptAllProposals(doc);
trackChangesPreviewController.refresh(doc);
if (applied === 0 && skipped === 0) return; if (applied === 0 && skipped === 0) return;
const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : ""; const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : "";
void vscode.window.showInformationMessage( void vscode.window.showInformationMessage(
@@ -242,7 +224,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
return; return;
} }
const { reverted } = await proposalController.rejectAll(doc); const { reverted } = await proposalController.rejectAll(doc);
trackChangesPreviewController.refresh(doc);
if (reverted > 0) { if (reverted > 0) {
void vscode.window.showInformationMessage( void vscode.window.showInformationMessage(
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`, `Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`,
@@ -333,11 +314,13 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
); );
// Code-review fix (Finding 1, session native-surfaces-exec): `cowriting.editDocument` // Code-review fix (Finding 1, session native-surfaces-exec): `cowriting.editDocument`
// used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompts via // used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompted via
// `askEditInstruction` — a rejecting stub since Task 6 sunset the input webview // `askEditInstruction` — a rejecting stub after Task 6 sunset the input webview
// (spec §6.10). Reached with no selection (the common case for `cowriting.edit`), // (spec §6.10; both `askClaude`/`askEditInstruction` were deleted from EditFlow in
// that was a SILENT dead end: the reject fired before EditFlow.askClaude's own try/ // Task 8 once their only caller, the review webview, died too). Reached with no
// catch and was `void`'d, so no toast, no thread, nothing. This command now resolves // selection (the common case for `cowriting.edit`), that was a SILENT dead end: the
// reject fired before EditFlow.askClaude's own try/catch and was `void`'d, so no
// toast, no thread, nothing. This command now resolves
// its target document exactly as before (a tab's clicked URI, falling back to the // its target document exactly as before (a tab's clicked URI, falling back to the
// active editor), focuses it, and hands off to the SAME comments-first ask as // active editor), focuses it, and hands off to the SAME comments-first ask as
// `cowriting.editSelection` — `ThreadController.askClaude()` top-anchors a // `cowriting.editSelection` — `ThreadController.askClaude()` top-anchors a
@@ -386,6 +369,33 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
}), }),
); );
// Task 8 (native-surfaces migration, spec §6.10): the minimal gateway replacing
// the sunset review-webview's #41 right-click entries. Resolves the clicked
// doc (or falls back to the active editor, mirroring editDocument/#41), enters
// coediting if not already (so a fresh doc gets a baseline to review against —
// no-op if already entered or not authorable), then hands off to VS Code's
// OWN "Open Preview to the Side" — the built-in preview IS the review surface
// now (Task 7's annotations render inside it).
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.openReviewPreview", async (uri?: vscode.Uri) => {
const doc = uri
? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ??
(await vscode.workspace.openTextDocument(uri))
: vscode.window.activeTextEditor?.document;
if (!doc || doc.languageId !== "markdown") {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to review it.");
return;
}
if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) {
await vscode.window.showTextDocument(doc);
}
if (!coeditingRegistry.isCoediting(doc.uri) && isAuthorable(doc.uri.scheme)) {
await vscode.commands.executeCommand("cowriting.coeditDocument");
}
await vscode.commands.executeCommand("markdown.showPreviewToSide", doc.uri);
}),
);
// Render threads + attributions for already-open editors, and on future opens. // Render threads + attributions for already-open editors, and on future opens.
// INV-10: gated on the registry too — belt-and-suspenders with the per-controller // INV-10: gated on the registry too — belt-and-suspenders with the per-controller
// gates (renderAll/loadAll already early-return), but keeps the intent explicit // gates (renderAll/loadAll already early-return), but keeps the intent explicit
@@ -456,7 +466,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
proposalController, proposalController,
versionGuard, versionGuard,
diffViewController, diffViewController,
trackChangesPreviewController,
editFlow, editFlow,
sidecarRouter, sidecarRouter,
liveProgressUi, liveProgressUi,
-391
View File
@@ -1,391 +0,0 @@
/**
* TrackChangesPreviewController — F7 vscode layer (spec §6.2/§6.4). Owns one
* sealed webview panel per markdown document, beside the source editor. On open /
* debounced edit / F6 baseline-epoch change it reads the baseline (from the
* reused DiffViewController) + the live buffer, runs the pure render engine, and
* posts the HTML. Pure read-only: never mutates the document, sidecar, or
* baseline (INV-20). The webview is sealed: local assets only, strict CSP,
* per-load nonce, no network (INV-21).
*/
import * as path from "node:path";
import { randomBytes } from "node:crypto";
import * as vscode from "vscode";
import type { DiffViewController } from "./diffViewController";
import type { AttributionController } from "./attributionController";
import type { ProposalController } from "./proposalController";
import { renderReview, renderPlain, diffBlocks, landedTextOf, type BlockOp } from "./trackChangesModel";
import { isAuthorable } from "./workspacePath";
import type { EditFlow, EditTarget } from "./editFlow";
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 }
| { type: "acceptAll" }
| { type: "rejectAll" };
export class TrackChangesPreviewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly panels = new Map<string, vscode.WebviewPanel>();
private readonly lastModel = new Map<string, BlockOp[]>();
private readonly debounces = new Map<string, NodeJS.Timeout>();
/** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */
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);
constructor(
private readonly diffView: DiffViewController,
private readonly extensionUri: vscode.Uri,
private readonly attribution: AttributionController,
private readonly proposals: ProposalController,
private readonly editFlow: EditFlow,
) {
this.disposables.push(
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
// the palette / keybinding pass nothing → fall back to the active editor.
// #41: the explorer/tab right-click also passes the clicked Uri, which may
// not be an open document yet — open it so we preview the clicked file, not
// whatever happens to be the active editor.
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", async (uri?: vscode.Uri) => {
if (uri) {
const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
this.show(open ?? (await vscode.workspace.openTextDocument(uri)));
return;
}
this.show(vscode.window.activeTextEditor?.document);
}),
vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
this.proposals.onDidChangeProposals(({ uri }) => {
this.refreshByUri(uri);
this.updateStatus(uri);
}),
this.statusItem,
);
this.statusItem.command = "cowriting.showTrackChangesPreview";
}
private isMarkdown(document: vscode.TextDocument): boolean {
return document.languageId === "markdown";
}
/** Open or reveal the preview for a markdown document (PUC-1). */
show(document: vscode.TextDocument | undefined): void {
if (!document || !this.isMarkdown(document)) {
void vscode.window.showWarningMessage(
"Cowriting: open a Markdown document to use the track-changes preview (F6 covers other files).",
);
return;
}
const key = document.uri.toString();
const existing = this.panels.get(key);
if (existing) {
existing.reveal(vscode.ViewColumn.Beside);
this.refresh(document);
return;
}
const name = path.basename(document.uri.path) || "untitled";
const panel = vscode.window.createWebviewPanel(
VIEW_TYPE,
`Review: ${name}`,
{ viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },
{
enableScripts: true,
retainContextWhenHidden: false,
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "out", "media")],
},
);
panel.webview.html = this.shellHtml(panel.webview);
panel.onDidDispose(
() => {
this.panels.delete(key);
this.lastModel.delete(key);
this.mode.delete(key);
// A panel is gone: re-show the off-panel indicator if proposals remain.
this.updateStatus(key);
},
null,
this.disposables,
);
// 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: 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);
} else if (m?.type === "accept" && m.proposalId) {
void this.proposals
.acceptById(this.proposals.keyFor(document), m.proposalId)
.then(() => this.refresh(document));
} else if (m?.type === "reject" && m.proposalId) {
void this.proposals.rejectByIdInPlace(this.proposals.keyFor(document), m.proposalId).then(() => 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.editFlow.askClaude(document, target);
} else if (m?.type === "acceptAll") {
// #46 (INV-42): batch-accept every pending proposal on this doc, then report.
void this.acceptAll(document);
} else if (m?.type === "rejectAll") {
void this.rejectAll(document);
}
}
/**
* #46 (INV-42): apply every pending proposal on the document through the F4
* accept seam (orphan-skip) and report the applied-vs-skipped tally. No
* confirmation dialog — VS Code undo restores (parity with single accept).
* Public so the `cowriting.acceptAllProposals` command can reach it for the
* active doc (not only the webview button).
*/
async acceptAll(document: vscode.TextDocument): Promise<void> {
const { applied, skipped } = await this.proposals.acceptAllProposals(document);
this.refresh(document);
if (applied === 0 && skipped === 0) return;
const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : "";
void vscode.window.showInformationMessage(
`Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`,
);
}
/** #64 (INV-53): revert every pending proposal on the document; report the count. */
async rejectAll(document: vscode.TextDocument): Promise<void> {
const { reverted } = await this.proposals.rejectAll(document);
this.refresh(document);
if (reverted > 0) {
void vscode.window.showInformationMessage(
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`,
);
}
}
private onEdit(document: vscode.TextDocument): void {
const key = document.uri.toString();
if (!this.panels.has(key)) return;
const pending = this.debounces.get(key);
if (pending) clearTimeout(pending);
this.debounces.set(
key,
setTimeout(() => {
this.debounces.delete(key);
this.refresh(document);
}, DEBOUNCE_MS),
);
}
private refreshByUri(uri: string): void {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) this.refresh(doc);
}
/** Recompute the model + post HTML to the panel for its current mode (no-op if no panel). */
refresh(document: vscode.TextDocument): void {
const key = document.uri.toString();
const panel = this.panels.get(key);
if (!panel) return;
const mode = this.mode.get(key) ?? "on";
const current = document.getText();
const baseline = this.diffView.getBaseline(key);
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), authorable });
return;
}
const spans = this.attribution.spansFor(document);
const proposals = this.proposals.listProposals(document);
// F12/#64 (INV-50): count added/removed against the LANDED text (current minus
// pending proposals), matching the body — a pending change shows once, as a
// proposal, and is not also tallied as a landed add/remove.
const landedOps = diffBlocks(baselineText, landedTextOf(current, proposals));
const summary = {
added: landedOps.filter((o) => o.kind === "added").length,
removed: landedOps.filter((o) => o.kind === "removed").length,
proposals: proposals.length,
};
void panel.webview.postMessage({
type: "render",
mode,
html: renderReview(baselineText, current, spans, proposals, { pinned: baseline?.reason === "pinned" }),
epoch: this.epochLabel(baseline),
summary,
authorable,
});
}
/** F10 (PUC-6): off-panel proposal indicator on the active doc. Hidden when a panel is open. */
private updateStatus(uri: string): void {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (!doc) {
this.hideStatus();
return;
}
const n = this.proposals.listProposals(doc).length;
if (n === 0 || this.panels.has(uri)) {
this.hideStatus();
return;
}
this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`;
this.statusItem.tooltip = "Cowriting: open the review preview to accept/reject Claude's proposals";
this.statusItem.show();
}
/**
* Hide the off-panel indicator AND clear its text, so the `statusText()` seam
* is honest: a hidden indicator reports `undefined` (not its stale last value).
*/
private hideStatus(): void {
this.statusItem.text = "";
this.statusItem.hide();
}
private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string {
if (!baseline) return "not coediting (no baseline yet)";
const time = new Date(baseline.capturedAt).toLocaleTimeString();
switch (baseline.reason) {
case "head":
return `HEAD ${time}`;
case "pinned":
return `reviewed ${time}`;
default:
return `entered ${time}`;
}
}
private shellHtml(webview: vscode.Webview): string {
const nonce = randomBytes(16).toString("base64");
const scriptUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.js"),
);
const styleUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.css"),
);
// Sealed CSP (INV-21): no network. 'unsafe-inline' style is required only for
// mermaid's dynamically injected <style> tags; scripts are nonce-gated and
// strictly local (no remote/CDN script source).
const csp =
`default-src 'none'; ` +
`img-src ${webview.cspSource} data:; ` +
`font-src ${webview.cspSource}; ` +
`style-src ${webview.cspSource} 'unsafe-inline'; ` +
`script-src 'nonce-${nonce}';`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<link href="${styleUri}" rel="stylesheet" />
<title>Track changes</title>
</head>
<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>
<button id="cw-acceptall" type="button" hidden title="Accept every pending Claude proposal on this document">✓✓ Accept all</button>
<span id="cw-epoch">Review</span>
<span id="cw-summary"></span>
<span id="cw-legend"></span>
</div>
<div id="cw-body"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
// ---- test seam (§6.4) ----
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 (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);
}
/** F10 test seam: the review HTML the panel would post for a doc (on-state). */
renderHtmlFor(uriString: string): string {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (!doc) return "";
const current = doc.getText();
const baseline = this.diffView.getBaseline(uriString);
return renderReview(
baseline?.text ?? current,
current,
this.attribution.spansFor(doc),
this.proposals.listProposals(doc),
{ pinned: baseline?.reason === "pinned" },
);
}
/** F10: current annotations mode for a panel (default on). */
getMode(uriString: string): "on" | "off" {
return this.mode.get(uriString) ?? "on";
}
/** F10: set the annotations mode and re-render (the programmatic twin of the header toggle). */
setMode(uriString: string, mode: "on" | "off"): void {
this.mode.set(uriString, mode);
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (doc) this.refresh(doc);
}
/** F10 test seam (SLICE-4 E2E): the off-panel status-bar indicator text, if shown. */
statusText(): string | undefined {
return this.statusItem.text || undefined;
}
dispose(): void {
for (const t of this.debounces.values()) clearTimeout(t);
for (const p of this.panels.values()) p.dispose();
for (const d of this.disposables) d.dispose();
}
}
+8 -11
View File
@@ -3,6 +3,7 @@ import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import * as vscode from "vscode"; import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension"; import type { CowritingApi } from "../../../src/extension";
import { renderHtmlFor } from "./helpers";
const WS = process.env.E2E_WORKSPACE!; const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 300)); const settle = () => new Promise((r) => setTimeout(r, 300));
@@ -10,7 +11,7 @@ const settle = () => new Promise((r) => setTimeout(r, 300));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal"); assert.ok(api?.attributionController && api?.proposalController, "exports attribution + proposal");
return api; return api;
} }
@@ -20,14 +21,16 @@ async function getApi(): Promise<CowritingApi> {
// suite checks that render BEFORE accepting (the class to check is cw-ins-claude, // suite checks that render BEFORE accepting (the class to check is cw-ins-claude,
// the proposal block, NOT cw-by-claude — the old F9 authorship render) and that // the proposal block, NOT cw-by-claude — the old F9 authorship render) and that
// the attribution data layer survives the accept; it does not exercise the F6 // the attribution data layer survives the accept; it does not exercise the F6
// baseline (see diffView.test.ts / trackChangesPreview.test.ts / baselineRouter.test.ts // baseline (see diffView.test.ts / baselineRouter.test.ts for the post-accept
// for the post-accept baseline behavior, INV-7/D21). Owns its own markdown doc. // baseline behavior, INV-7/D21). Owns its own markdown doc. Task 8: the render
// probe is now the pure `renderReview` directly (the webview that used to wrap
// it, `renderHtmlFor`, is gone).
suite("F10 review preview — pending Claude proposal renders cw-ins-claude in the on-state (host E2E, no LLM)", () => { suite("F10 review preview — pending Claude proposal renders cw-ins-claude in the on-state (host E2E, no LLM)", () => {
const DOC_REL = "docs/f10claude.md"; const DOC_REL = "docs/f10claude.md";
const TARGET = "The sentence Claude will compose over."; const TARGET = "The sentence Claude will compose over.";
const REPLACEMENT = "The sentence CLAUDE COMPOSED via the seam."; const REPLACEMENT = "The sentence CLAUDE COMPOSED via the seam.";
test("a pending Claude proposal carries cw-ins-claude; accepted proposal leaves attribution data intact; mode defaults to on", async () => { test("a pending Claude proposal carries cw-ins-claude; accepted proposal leaves attribution data intact", async () => {
const abs = path.join(WS, DOC_REL); const abs = path.join(WS, DOC_REL);
fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, `# F10\n\n${TARGET}\n`, "utf8"); fs.writeFileSync(abs, `# F10\n\n${TARGET}\n`, "utf8");
@@ -41,12 +44,6 @@ suite("F10 review preview — pending Claude proposal renders cw-ins-claude in t
const api = await getApi(); const api = await getApi();
const key = uri.toString(); const key = uri.toString();
// open the preview — annotations default ON (F10/INV-33)
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open");
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default to on");
// Claude proposes via the seam (F12 optimistic-apply; proposal stays PENDING) // Claude proposes via the seam (F12 optimistic-apply; proposal stays PENDING)
const start = doc.getText().indexOf(TARGET); const start = doc.getText().indexOf(TARGET);
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", { const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
@@ -64,7 +61,7 @@ suite("F10 review preview — pending Claude proposal renders cw-ins-claude in t
// PENDING proposal: the preview renders the proposal block with cw-ins-claude. // PENDING proposal: the preview renders the proposal block with cw-ins-claude.
// After accepting, the baseline advances (INV-18) → the text becomes "unchanged" // After accepting, the baseline advances (INV-18) → the text becomes "unchanged"
// and renders plain — so we check BEFORE accepting. // and renders plain — so we check BEFORE accepting.
const pendingHtml = api.trackChangesPreviewController.renderHtmlFor(key); const pendingHtml = renderHtmlFor(api, doc, key);
assert.ok( assert.ok(
pendingHtml.includes("cw-ins-claude"), pendingHtml.includes("cw-ins-claude"),
"pending Claude proposal block carries cw-ins-claude in the on-state render", "pending Claude proposal block carries cw-ins-claude in the on-state render",
-310
View File
@@ -1,310 +0,0 @@
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";
import { renderPlain } from "../../../src/trackChangesModel";
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?.proposalController, "exports preview + proposal");
return api;
}
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key.
* Also enters coediting (INV-10/Task 2): the baseline is established only on
* entry, not merely on open. */
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 vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return { doc, key: uri.toString() };
}
async function propose(
doc: vscode.TextDocument,
key: string,
target: string,
newText: string,
turnId: string,
): Promise<string> {
const start = doc.getText().indexOf(target);
assert.ok(start >= 0, `fixture contains "${target}"`);
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: key,
start,
end: start + target.length,
newText,
model: "sonnet",
sessionId: "e2e-f10rev",
turnId,
});
assert.ok(id, "propose returns an id");
return id!;
}
// F10 host E2E (no LLM): the rendered preview is the single INTERACTIVE review
// surface. This suite owns docs/f10review.md (its main flow is order-dependent)
// plus its own disjoint fresh docs for the isolated cases (status-bar, toggle).
// The editor is zero-decoration; everything observable here is the data layer +
// the on-state renderReview HTML the panel posts.
suite("F10 interactive review (host E2E — preview is the single review surface, no LLM)", () => {
const DOC_REL = "docs/f10review.md";
const PROSE = "The original review paragraph that lives in this doc.";
const T1 = "A first claude target sentence here.";
const T2 = "A second claude target sentence here.";
test("open on a markdown doc → panel open, fresh baseline all-unchanged, mode is on (PUC-1)", async () => {
const { doc, key } = await freshDoc(DOC_REL, `# F10 review\n\n${PROSE}\n\n${T1}\n\n${T2}\n`);
const api = await getApi();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel yet");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default on (INV-33)");
const model = api.trackChangesPreviewController.getLastModel(key);
assert.ok(model && model.length > 0, "a model was computed");
assert.ok(model!.every((o) => o.kind === "unchanged"), "fresh baseline == buffer → every block unchanged");
void doc;
});
test("typing produces an added/changed block and a cw-ins-human span in the on-state render (PUC-2)", async () => {
const { key } = await reopen(DOC_REL);
const api = await getApi();
const doc = byKey(key)!;
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle();
const kinds = (api.trackChangesPreviewController.getLastModel(key) ?? []).map((o) => o.kind);
assert.ok(kinds.some((k) => k === "added" || k === "changed"), "an added/changed block after typing");
// Attribution recorded the human span (data layer), and the on-state render
// author-colors that prose as cw-ins-human (added blocks use cw-ins-{author} since Task 3/44ef0a2).
assert.ok(
api.attributionController.spansFor(doc).some((s) => s.author === "human"),
"a human span was recorded for the typed text",
);
const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(html.includes("cw-ins-human"), "typed text is author-colored human in the on-state (cw-ins-human for added blocks)");
});
test("propose → the preview surfaces it as a cw-proposal block with ✓/✗ actions (PUC-3)", async () => {
const { doc, key } = await reopen(DOC_REL);
const api = await getApi();
const id = await propose(doc, key, T1, "A FIRST claude REPLACEMENT sentence.", "turn-f10-1");
await settle();
const views = api.proposalController.listProposals(doc);
assert.ok(views.some((v) => v.id === id), "listProposals returns a view with the id");
const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(html.includes(`data-proposal-id="${id}"`), "the preview renders the proposal block by id");
assert.match(html, /class="cw-actions"/, "the proposal block carries ✓/✗ actions");
// #31: the proposal renders INLINE at its anchor (right after T1's block), not
// trailing the whole document — so it appears BEFORE the following T2 block.
const pIdx = html.indexOf(`data-proposal-id="${id}"`);
const t2Idx = html.indexOf("second claude target");
assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)");
// F12 (INV-48): EditorProposalController auto-applies the proposal into the buffer;
// the replacement text is now in the document, proposal is still pending.
assert.ok(doc.getText().includes("A FIRST claude REPLACEMENT sentence."), "F12 optimistic-apply: replacement in buffer");
});
test("accept → the proposal lands, clears from the preview, and stays a visible change-since-baseline (PUC-4, INV-7/D21)", async () => {
const { doc, key } = await reopen(DOC_REL);
const api = await getApi();
const id = api.proposalController.listProposals(doc).find((v) => v.replaced === T1)!.id;
assert.ok(await api.proposalController.acceptById(DOC_REL, id), "accept applies via the seam");
await settle();
const replacement = "A FIRST claude REPLACEMENT sentence.";
assert.ok(doc.getText().includes(replacement), "replacement landed in the document");
assert.ok(!doc.getText().includes(T1), "original target gone");
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id), "proposal cleared from listProposals");
const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(!html.includes(`data-proposal-id="${id}"`), "the accepted proposal block is gone from the preview");
// Native-surfaces migration (INV-7/D21): the shipped machine-landing baseline
// advance (#48/INV-18) is retired — the landed text stays a visible change
// until "Mark Changes as Reviewed".
const model = api.trackChangesPreviewController.getLastModel(key) ?? [];
const marked = model.some((o) => o.kind !== "unchanged" && o.block.raw.includes("FIRST claude REPLACEMENT"));
assert.ok(marked, "the just-landed Claude text still renders as a change (baseline does not auto-advance)");
});
test("reject → the proposal vanishes and the document is reverted (PUC-5)", async () => {
const { doc, key } = await reopen(DOC_REL);
const api = await getApi();
const before = doc.getText();
const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2");
await settle();
assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending");
// F12: use rejectByIdInPlace to revert the optimistically-applied buffer edit.
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id2), "rejectByIdInPlace reverts + removes");
await settle();
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
assert.strictEqual(doc.getText(), before, "document reverted to pre-propose state");
const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview");
});
test("toggle annotations off → mode round-trips and the off-state render is plain (no cw- marks) (INV-33)", async () => {
// A fresh doc with a pending proposal so the on-state DOES carry a cw- mark,
// making the off-state's absence of marks meaningful (not tautological).
const { doc, key } = await freshDoc(
"docs/f10toggle.md",
"# F10 toggle\n\nA toggle target sentence to propose over.\n",
);
const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const id = await propose(doc, key, "A toggle target sentence to propose over.", "A TOGGLED replacement.", "turn-tog");
await settle();
// on-state: the proposal block + its actions are present.
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "starts on");
const onHtml = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(onHtml.includes(`data-proposal-id="${id}"`), "on-state shows the proposal block");
// toggle off → mode round-trips; the off-state body is plain markdown.
api.trackChangesPreviewController.setMode(key, "off");
await settle();
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "off", "mode flipped to off");
assert.ok(api.trackChangesPreviewController.isOpen(key), "panel stays open across the toggle");
// renderHtmlFor is the on-state seam; the off-state body is renderPlain(current)
// (INV-33). Assert the actual off-state body the controller posts has no cw-
// author/proposal marks — meaningful because the on-state above DID carry one.
const offBody = renderPlain(doc.getText());
assert.ok(!/cw-proposal|cw-by-|cw-ins-|cw-del/.test(offBody), "off-state render carries no cw- marks");
// toggle back on → marks return.
api.trackChangesPreviewController.setMode(key, "on");
await settle();
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "mode flipped back on");
assert.ok(
api.trackChangesPreviewController.renderHtmlFor(key).includes(`data-proposal-id="${id}"`),
"on-state shows the proposal block again",
);
});
test("status-bar (PUC-6): a pending proposal with NO panel shows the indicator; opening the preview hides it", async () => {
// Isolated fresh doc: no preview opened, so the off-panel indicator is live.
const { doc, key } = await freshDoc("docs/f10status.md", "# F10 status\n\nA status target sentence here.\n");
const api = await getApi();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for this doc");
await propose(doc, key, "A status target sentence here.", "A STATUS replacement.", "turn-stat");
await settle();
const text = api.trackChangesPreviewController.statusText();
assert.ok(text && text.length > 0, "the off-panel indicator shows a non-empty status");
assert.match(text!, /1 Claude proposal/, "it mentions the pending count");
// open the preview for this doc → the off-panel indicator hides (undefined).
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
assert.strictEqual(api.trackChangesPreviewController.statusText(), undefined, "indicator hidden once the panel is open");
});
test("clean editor: data layers intact AND the retired in-editor surfaces are gone (INV-32)", async () => {
const { doc, key } = await freshDoc("docs/f10clean.md", "# F10 clean\n\nA clean target sentence here.\n");
const api = await getApi();
const id = await propose(doc, key, "A clean target sentence here.", "A CLAUDE clean replacement.", "turn-clean");
await settle();
assert.ok(await api.proposalController.acceptById("docs/f10clean.md", id), "accept lands the Claude edit");
await settle();
// data layer intact: a Claude (agent) attribution span exists.
assert.ok(
api.attributionController.getSpans("docs/f10clean.md").some((s) => s.authorKind === "agent"),
"agent span recorded (attribution data layer intact)",
);
// the retired in-editor surfaces are gone from the palette (no editor decorations — INV-32).
const all = await vscode.commands.getCommands(true);
assert.ok(!all.includes("cowriting.toggleAttribution"), "cowriting.toggleAttribution is retired");
// #34: the F6 two-pane diff VIEW was removed (F10 preview is the single review
// surface) — its toggle command + ctrl+alt+d keybinding are gone entirely.
assert.ok(!all.includes("cowriting.toggleDiffView"), "cowriting.toggleDiffView command is gone (#34)");
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const dKb = (pkg.contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find(
(k) => k.command === "cowriting.toggleDiffView",
);
assert.ok(!dKb, "the toggleDiffView keybinding is gone from package.json (#34)");
// …but the F6 baseline data layer survives: markReviewed stays a real command.
assert.ok(all.includes("cowriting.markReviewed"), "markReviewed (baseline data layer) is kept");
void key;
});
test("author-colored track changes: cw-ins-human + cw-ins-claude appear; unchanged blocks are plain (Tasks 1-4)", async () => {
// Fresh doc with three blocks: one that stays unchanged, one for Claude to target, one for human typing.
const { doc, key } = await freshDoc(
"docs/f10authorcolors.md",
"# Author Colors\n\nUnchanged paragraph that stays.\n\nClaude target block.\n",
);
const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// Human types a new paragraph → an "added" block relative to the opened baseline.
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nHuman typed paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "human edit applied");
await settle();
// Propose a Claude change (stays PENDING — F12 optimistic-apply; NOT accepted).
// The proposal block renders with cw-ins-claude.
const target = "Claude target block.";
const targetStart = doc.getText().indexOf(target);
assert.ok(targetStart >= 0, "target text present after human edit");
await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: key,
start: targetStart,
end: targetStart + target.length,
newText: "Claude replacement block.",
model: "sonnet",
sessionId: "e2e-f10-colors",
turnId: "turn-colors-1",
});
await settle();
const html = api.trackChangesPreviewController.renderHtmlFor(key);
// The human-typed added block renders with cw-ins-human.
assert.ok(html.includes("cw-ins-human"), "human added block is colored cw-ins-human");
// The pending Claude proposal block renders with cw-ins-claude.
assert.ok(html.includes("cw-ins-claude"), "pending Claude proposal block carries cw-ins-claude");
// The unchanged block renders as cw-unchanged with no cw-ins-/cw-by- inside it
// (color = "changed since baseline"; unchanged text is never annotated).
const unchangedBlocks = [...html.matchAll(/<div class="cw-blk cw-unchanged"[^>]*>([\s\S]*?)<\/div>/g)];
assert.ok(unchangedBlocks.length > 0, "at least one unchanged block exists");
assert.ok(
unchangedBlocks.some((m) => m[1].includes("Unchanged paragraph that stays")),
"the unchanged paragraph is in a cw-unchanged block",
);
assert.ok(
unchangedBlocks.every((m) => !/cw-ins-|cw-by-/.test(m[1])),
"unchanged blocks carry no cw-ins- or cw-by- classes",
);
});
});
// ---- helpers reused across the order-dependent main-flow tests ----
function byKey(key: string): vscode.TextDocument | undefined {
return vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
}
async function reopen(rel: string): Promise<{ doc: vscode.TextDocument; key: string }> {
const uri = vscode.Uri.file(path.join(WS, rel));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
// Idempotent (INV-10): the doc entered coediting in freshDoc(); re-entering
// is a no-op that leaves the once-captured baseline untouched.
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return { doc, key: uri.toString() };
}
+13 -89
View File
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.diffViewController, "exports preview + diffView"); assert.ok(api?.editFlow && api?.diffViewController, "exports editFlow + diffView");
return api; return api;
} }
@@ -29,36 +29,15 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
return { doc, key: uri.toString() }; return { doc, key: uri.toString() };
} }
// F11 host E2E (no LLM): the preview toolbar is the primary interaction surface. // F11/F12 host E2E (no LLM): EditFlow.runEditAndPropose is the turn→proposal(s)
// The webview posts intent messages; the host routes them through the existing // cut shared by every Ask-Claude entry point (comments-first ask, editDocument,
// F4/F6/F3 seams (INV-35). The webview DOM (real button clicks) is sealed and // editSelection). Originally exercised via the now-sunset review webview's
// manual-smoke only; here we simulate the inbound messages via `receiveMessage`. // toolbar (Task 8 deleted that webview and its message-routing seam,
suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () => { // `receiveMessage`/`pinBaseline` webview-message coverage duplicates
// SLICE-1: the Pin baseline button. // diffView.test.ts / baselineRouter.test.ts's `markReviewed` tests) — these
test("pinBaseline message pins the PREVIEWED doc → marks clear, baseline reason is pinned (PUC-5, INV-35)", async () => { // tests drive `runEditAndPropose` directly, the same programmatic seam the
const { doc, key } = await freshDoc("docs/f11pin.md", "# F11 pin\n\nA baseline paragraph that will diverge.\n"); // deleted toolbar itself called into.
const api = await getApi(); suite("F11/F12 — EditFlow document/selection edit turns (host E2E, no LLM)", () => {
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`. // SLICE-1 reachability: the orphaned pin command gets a real palette `when`.
// Renamed cowriting.pinDiffBaseline → cowriting.markReviewed (Task 2, D14). // Renamed cowriting.pinDiffBaseline → cowriting.markReviewed (Task 2, D14).
test("markReviewed is reachable from the command palette (when: editorLangId == markdown)", async () => { test("markReviewed is reachable from the command palette (when: editorLangId == markdown)", async () => {
@@ -81,8 +60,6 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
); );
const api = await getApi(); const api = await getApi();
const editFlow = api.editFlow; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// Stub the host edit turn (no LLM in CI): rewrite two distinct words. // Stub the host edit turn (no LLM in CI): rewrite two distinct words.
editFlow.setEditTurnForTest(async () => ({ editFlow.setEditTurnForTest(async () => ({
@@ -103,8 +80,9 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
"The quick RED fox jumps over the lazy CAT.", "The quick RED fox jumps over the lazy CAT.",
"the block proposal carries the whole rewritten paragraph", "the block proposal carries the whole rewritten paragraph",
); );
// F12 (INV-48): EditorProposalController optimistically applies the proposed text, // F12 (INV-48): EditorProposalController optimistically applies the proposed
// so after settle the buffer has the replacement. The proposal is still pending. // text, so after settle the buffer has the replacement. The proposal is still
// pending (not finalized) until Accept.
assert.ok(doc.getText().includes("RED fox") && doc.getText().includes("lazy CAT"), "F12 optimistic-apply: proposed text in buffer"); assert.ok(doc.getText().includes("RED fox") && doc.getText().includes("lazy CAT"), "F12 optimistic-apply: proposed text in buffer");
void key; void key;
}); });
@@ -115,8 +93,6 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
const { doc, key } = await freshDoc("docs/f11sel.md", body); const { doc, key } = await freshDoc("docs/f11sel.md", body);
const api = await getApi(); const api = await getApi();
const editFlow = api.editFlow; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const target = "The target paragraph Claude will rewrite."; const target = "The target paragraph Claude will rewrite.";
const start = doc.getText().indexOf(target); const start = doc.getText().indexOf(target);
@@ -145,8 +121,6 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
const { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n"); const { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n");
const api = await getApi(); const api = await getApi();
const editFlow = api.editFlow; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const target = "Leave me exactly as I am."; const target = "Leave me exactly as I am.";
const start = doc.getText().indexOf(target); const start = doc.getText().indexOf(target);
editFlow.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" })); editFlow.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" }));
@@ -163,8 +137,6 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
const { doc } = await freshDoc("docs/f11accept.md", original); const { doc } = await freshDoc("docs/f11accept.md", original);
const api = await getApi(); const api = await getApi();
const editFlow = api.editFlow; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" })); editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "expand the sentence"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "expand the sentence");
@@ -198,52 +170,4 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
assert.ok(editEntry, "cowriting.edit has a commandPalette entry"); assert.ok(editEntry, "cowriting.edit has a commandPalette entry");
assert.match(editEntry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); assert.match(editEntry!.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();
}
});
}); });
+8 -13
View File
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers"); assert.ok(api?.editFlow && api?.proposalController, "exports controllers");
return api; return api;
} }
@@ -36,20 +36,18 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
test("acceptAll applies every pending proposal and reconstructs the document", async () => { test("acceptAll applies every pending proposal and reconstructs the document", async () => {
const original = "# All\n\nFirst para alpha.\n\nSecond para beta.\n\nThird para gamma.\n"; const original = "# All\n\nFirst para alpha.\n\nSecond para beta.\n\nThird para gamma.\n";
const rewrite = "# All\n\nFirst para ALPHA.\n\nSecond para BETA.\n\nThird para GAMMA.\n"; const rewrite = "# All\n\nFirst para ALPHA.\n\nSecond para BETA.\n\nThird para GAMMA.\n";
const { doc, key } = await freshDoc("docs/f12-all.md", original); const { doc } = await freshDoc("docs/f12-all.md", original);
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController;
const editFlow = api.editFlow; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" })); editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" }));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns");
await settle(); await settle();
assert.strictEqual(ids.length, 3, "three changed blocks → three pending proposals"); assert.strictEqual(ids.length, 3, "three changed blocks → three pending proposals");
// Simulate the toolbar "Accept all" button posting its intent. // Route through the real "Keep all" command (Task 8, PUC-2) — the doc is the
ctl.receiveMessage(key, { type: "acceptAll" }); // active editor from freshDoc, matching the top-of-file CodeLens's own target.
await vscode.commands.executeCommand("cowriting.acceptAllProposals");
await settle(); await settle();
await settle(); await settle();
@@ -65,8 +63,6 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
const { doc } = await freshDoc("docs/f12-orphan.md", original); const { doc } = await freshDoc("docs/f12-orphan.md", original);
const api = await getApi(); const api = await getApi();
const editFlow = api.editFlow; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" })); editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" }));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase");
@@ -96,14 +92,13 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
assert.strictEqual(api.proposalController.listProposals(doc).length, 1, "the orphaned proposal remains pending"); assert.strictEqual(api.proposalController.listProposals(doc).length, 1, "the orphaned proposal remains pending");
}); });
// A single pending proposal still applies through the batch path (the button is // A single pending proposal still applies through the batch path (the
// hidden < 2 pending in the webview, but the command/seam handle any count). // top-of-file "Keep all" CodeLens is hidden < 2 pending, Task 8 PUC-2, but the
// command/seam handle any count).
test("acceptAllProposals with one pending proposal applies it", async () => { test("acceptAllProposals with one pending proposal applies it", async () => {
const { doc } = await freshDoc("docs/f12-one.md", "# One\n\nThe only paragraph here.\n"); const { doc } = await freshDoc("docs/f12-one.md", "# One\n\nThe only paragraph here.\n");
const api = await getApi(); const api = await getApi();
const editFlow = api.editFlow; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
editFlow.setEditTurnForTest(async () => ({ editFlow.setEditTurnForTest(async () => ({
replacement: "# One\n\nThe ONLY paragraph here.\n", replacement: "# One\n\nThe ONLY paragraph here.\n",
model: "sonnet", model: "sonnet",
+12 -12
View File
@@ -12,7 +12,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers"); assert.ok(api?.diffViewController && api?.proposalController, "exports controllers");
return api; return api;
} }
@@ -34,8 +34,6 @@ suite("F12 inline diff — seam landBaseline (#64, INV-48)", () => {
test("applyAgentEdit with landBaseline:false applies text but does NOT advance the baseline", async () => { test("applyAgentEdit with landBaseline:false applies text but does NOT advance the baseline", async () => {
const { doc, key } = await freshDoc("docs/f12-land.md", "# T\n\nalpha here.\n"); const { doc, key } = await freshDoc("docs/f12-land.md", "# T\n\nalpha here.\n");
const api = await getApi(); const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const before = api.diffViewController.getBaseline(key)?.text ?? doc.getText(); const before = api.diffViewController.getBaseline(key)?.text ?? doc.getText();
const start = doc.getText().indexOf("alpha"); const start = doc.getText().indexOf("alpha");
const ok = await api.attributionController.applyAgentEdit( const ok = await api.attributionController.applyAgentEdit(
@@ -171,23 +169,25 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
}); });
suite("F12 inline diff — control parity (#64, INV-53)", () => { suite("F12 inline diff — control parity (#64, INV-53)", () => {
test("reject from the webview reverts in place; rejectAll clears every proposal", async () => { test("reject-one via revertInPlace reverts in place; rejectAll clears every proposal", async () => {
const { doc, key } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n"); const { doc } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const p = api.proposalController;
const editFlow = api.editFlow; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
editFlow.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" })); editFlow.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" }));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle(); await settle(); await settle();
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB")); assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"));
// reject ONE via the webview intent → that block reverts, the other stays applied // reject ONE via the same seam the per-proposal "✗ Reject" CodeLens uses
ctl.receiveMessage(key, { type: "reject", proposalId: ids[0] }); // (Task 8, PUC-2) → that block reverts, the other stays applied.
const docKey = p.keyFor(doc);
assert.ok(await p.revertInPlace(docKey, ids[0]), "revert one proposal");
await settle(); await settle(); await settle(); await settle();
assert.ok(doc.getText().includes("uno aaa.") && doc.getText().includes("BBB"), "one reverted, one applied"); assert.ok(doc.getText().includes("uno aaa.") && doc.getText().includes("BBB"), "one reverted, one applied");
// rejectAll via the command → all gone, document restored // rejectAll via the same seam the top-of-file "✗ Reject all" CodeLens uses →
ctl.receiveMessage(key, { type: "rejectAll" }); // all gone, document restored.
const { reverted } = await p.rejectAll(doc);
assert.strictEqual(reverted, 1, "the one remaining proposal reverted");
await settle(); await settle(); await settle(); await settle();
assert.strictEqual(doc.getText(), "# P\n\nuno aaa.\n\ndos bbb.\n", "document restored"); assert.strictEqual(doc.getText(), "# P\n\nuno aaa.\n\ndos bbb.\n", "document restored");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0); assert.strictEqual(api.proposalController.listProposals(doc).length, 0);
+1 -1
View File
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "exports preview controller"); assert.ok(api?.threadController, "exports thread controller");
return api; return api;
} }
+53 -1
View File
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers"); assert.ok(api?.proposalController && api?.editorProposalController, "exports controllers");
return api; return api;
} }
@@ -135,3 +135,55 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending"); assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
}); });
}); });
// Task 8 (PUC-2 lens copy): the CodeLens titles read "✓ Keep"/"✗ Reject" per
// proposal, and — once ≥2 proposals are pending — a top-of-file "Keep all
// (N)"/"Reject all" pair routes straight at the batch commands.
suite("F12 CodeLens copy (Task 8, PUC-2)", () => {
test("one pending proposal → a per-proposal ✓ Keep / ✗ Reject pair, no top-of-file pair", async () => {
const doc = await freshDoc("docs/f12-lens-one.md", "# Lens\n\nAlpha block here.\n");
const api = await getApi();
const editFlow = api.editFlow;
editFlow.setEditTurnForTest(async () => ({
replacement: "# Lens\n\nALPHA block here.\n",
model: "sonnet",
sessionId: "e2e-lens-one",
}));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase");
await settle();
assert.strictEqual(ids.length, 1, "one changed block → one proposal");
const lenses = api.editorProposalController.provideCodeLenses(doc);
const titles = lenses.map((l) => l.command?.title);
assert.deepStrictEqual(titles, ["✓ Keep", "✗ Reject"], "exactly one per-proposal pair, no top-of-file pair below threshold");
});
test("two pending proposals → a top-of-file 'Keep all (2)'/'Reject all' pair routed at the batch commands", async () => {
const doc = await freshDoc(
"docs/f12-lens-two.md",
"# Lens\n\nFirst block here.\n\nSecond block here.\n",
);
const api = await getApi();
const editFlow = api.editFlow;
editFlow.setEditTurnForTest(async () => ({
replacement: "# Lens\n\nFIRST block here.\n\nSECOND block here.\n",
model: "sonnet",
sessionId: "e2e-lens-two",
}));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase both");
await settle();
assert.strictEqual(ids.length, 2, "two changed blocks → two proposals");
const lenses = api.editorProposalController.provideCodeLenses(doc);
const titles = lenses.map((l) => l.command?.title);
assert.strictEqual(titles[0], "✓ Keep all (2)", "top-of-file accept-all lens leads");
assert.strictEqual(titles[1], "✗ Reject all", "top-of-file reject-all lens follows");
assert.deepStrictEqual(titles.slice(2), ["✓ Keep", "✗ Reject", "✓ Keep", "✗ Reject"], "per-proposal pairs follow");
const acceptAll = lenses[0].command!;
const rejectAll = lenses[1].command!;
assert.strictEqual(acceptAll.command, "cowriting.acceptAllProposals", "top-of-file accept-all routes at the batch command");
assert.strictEqual(rejectAll.command, "cowriting.rejectAllProposals", "top-of-file reject-all routes at the batch command");
assert.deepStrictEqual(acceptAll.arguments, undefined, "acceptAllProposals takes no arguments (targets the active editor)");
});
});
+20
View File
@@ -6,6 +6,7 @@
import * as assert from "node:assert"; import * as assert from "node:assert";
import * as vscode from "vscode"; import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension"; import type { CowritingApi } from "../../../src/extension";
import { renderReview } from "../../../src/trackChangesModel";
/** Activate the extension and return its exported API (asserts it is real). */ /** Activate the extension and return its exported API (asserts it is real). */
export async function activateApi(): Promise<CowritingApi> { export async function activateApi(): Promise<CowritingApi> {
@@ -15,6 +16,25 @@ export async function activateApi(): Promise<CowritingApi> {
return api; return api;
} }
/**
* Task 8: the pure render probe the deleted TrackChangesPreviewController used
* to wrap (`renderHtmlFor`) — the sunset webview was a thin shell around this
* exact call (F6 baseline + F3 spans + F4 pending proposals -> `renderReview`).
* Suites that used to read `controller.renderHtmlFor(key)` now call this
* directly against the surviving controllers; no webview involved.
*/
export function renderHtmlFor(api: CowritingApi, doc: vscode.TextDocument, key: string): string {
const baseline = api.diffViewController.getBaseline(key);
const current = doc.getText();
return renderReview(
baseline?.text ?? current,
current,
api.attributionController.spansFor(doc),
api.proposalController.listProposals(doc),
{ pinned: baseline?.reason === "pinned" },
);
}
/** A short fixed settle, for state that updates synchronously-ish after a command. */ /** A short fixed settle, for state that updates synchronously-ish after a command. */
export function settle(): Promise<void> { export function settle(): Promise<void> {
return new Promise((r) => setTimeout(r, 150)); return new Promise((r) => setTimeout(r, 150));
+1 -5
View File
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "exports preview controller"); assert.ok(api?.editFlow && api?.proposalController, "exports edit flow + proposal controller");
return api; return api;
} }
@@ -37,8 +37,6 @@ suite("#60 live turn progress (additive + cancel)", () => {
const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n"); const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n");
const api = await getApi(); const api = await getApi();
const editFlow = api.editFlow; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// The stub honors opts.onProgress (emitting synthetic snapshots) but returns // The stub honors opts.onProgress (emitting synthetic snapshots) but returns
// the same rewrite — proposals must be unaffected by progress events. // the same rewrite — proposals must be unaffected by progress events.
@@ -63,8 +61,6 @@ suite("#60 live turn progress (additive + cancel)", () => {
const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n"); const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n");
const api = await getApi(); const api = await getApi();
const editFlow = api.editFlow; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// The stub throws ONLY when the aborted signal reached it — so if opts.signal // The stub throws ONLY when the aborted signal reached it — so if opts.signal
// failed to thread through runEditAndPropose, the stub would instead return a // failed to thread through runEditAndPropose, the stub would instead return a
+82 -37
View File
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "exports preview controller"); assert.ok(api?.coeditingRegistry, "exports coeditingRegistry");
return api; return api;
} }
@@ -26,14 +26,19 @@ function pkg(): any {
return JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); return JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
} }
// #41 (story): "Open Cowriting Review Panel" reachable from the markdown // Task 8 (native-surfaces migration, spec §6.10): the review-webview's #41
// file/tab right-click menu. The review surface (showTrackChangesPreview) is the // right-click entries ("Open Cowriting Review Panel") are replaced by
// plugin's central affordance; this adds explorer/context + editor/title/context // `cowriting.openReviewPreview` — a minimal wrapper that resolves the clicked
// entry points and makes the command open the *clicked* document — not merely // document (or falls back to the active editor, mirroring #41's original
// the active editor — so the explorer right-click works even when the file is // behavior), enters coediting if not already (so there is a baseline to review
// not already open. // against), and hands off to VS Code's OWN "Open Preview to the Side"
suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-doc resolution)", () => { // (`markdown.showPreviewToSide`) — the built-in preview IS the review surface
test("command with a Uri previews the CLICKED doc, not the active editor", async () => { // now (Task 7's annotations render inside it). There is no controller-level
// `isOpen` seam anymore (no bespoke webview to introspect); these tests observe
// the command's OWN side effects instead — which document it focused/entered
// coediting on — the same signal #41's original suite checked via `isOpen`.
suite("Open Cowriting Review Preview (host E2E — menu wiring + clicked-doc resolution, Task 8)", () => {
test("command with a Uri targets the CLICKED doc, not the active editor", async () => {
// Two markdown docs; A is the active editor, B is the right-clicked target. // Two markdown docs; A is the active editor, B is the right-clicked target.
const aUri = writeFile("docs/menu-active.md", "# Active\n\nThe active editor's document.\n"); const aUri = writeFile("docs/menu-active.md", "# Active\n\nThe active editor's document.\n");
const bUri = writeFile("docs/menu-clicked.md", "# Clicked\n\nThe right-clicked document.\n"); const bUri = writeFile("docs/menu-clicked.md", "# Clicked\n\nThe right-clicked document.\n");
@@ -43,19 +48,20 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
await settle(); await settle();
const api = await getApi(); const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview", bUri); await vscode.commands.executeCommand("cowriting.openReviewPreview", bUri);
await settle(); await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(bUri.toString()), true, "preview opened for the clicked doc"); // `markdown.showPreviewToSide` itself ends with the preview WEBVIEW focused
assert.strictEqual( // (not a text editor), so `activeTextEditor` is not a stable post-command
api.trackChangesPreviewController.isOpen(aUri.toString()), // signal — `isCoediting` (set by the command's OWN resolve-and-enter step,
false, // before it hands off to the preview) is: the clicked doc B entered, the
"the active editor's doc did NOT get a preview", // still-open-but-not-clicked doc A did not.
); assert.strictEqual(api.coeditingRegistry.isCoediting(bUri), true, "the clicked doc B entered coediting (a baseline to review against)");
assert.strictEqual(api.coeditingRegistry.isCoediting(aUri), false, "the active-but-not-clicked doc A did NOT enter coediting");
assert.ok(bDoc, "clicked doc handle held"); assert.ok(bDoc, "clicked doc handle held");
}); });
test("command with a Uri for a not-yet-open file opens it and previews (explorer case)", async () => { test("command with a Uri for a not-yet-open file opens it and focuses it (explorer case)", async () => {
// Write a file but do not openTextDocument it — mimics an Explorer right-click. // Write a file but do not openTextDocument it — mimics an Explorer right-click.
const uri = writeFile("docs/menu-unopened.md", "# Unopened\n\nNever opened before the right-click.\n"); const uri = writeFile("docs/menu-unopened.md", "# Unopened\n\nNever opened before the right-click.\n");
const key = uri.toString(); const key = uri.toString();
@@ -65,10 +71,14 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
"precondition: the file is not an open document", "precondition: the file is not an open document",
); );
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview", uri); await vscode.commands.executeCommand("cowriting.openReviewPreview", uri);
await settle(); await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "the clicked-but-unopened file got a preview"); assert.ok(
vscode.workspace.textDocuments.some((d) => d.uri.toString() === key),
"the clicked-but-unopened file was opened",
);
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), true, "it entered coediting");
}); });
test("no-arg invocation still falls back to the active editor (palette / keybinding unchanged)", async () => { test("no-arg invocation still falls back to the active editor (palette / keybinding unchanged)", async () => {
@@ -78,39 +88,74 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
await settle(); await settle();
const api = await getApi(); const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.openReviewPreview");
await settle(); await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(uri.toString()), true, "no-arg previews the active editor"); assert.strictEqual(api.coeditingRegistry.isCoediting(uri), true, "no-arg targets the active editor");
}); });
test("explorer/context contributes the review-panel item for markdown only", () => { test("a non-markdown doc: command warns, no coediting entered", async () => {
const uri = writeFile("docs/menu-notmd.txt", "Not markdown.\n");
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settle();
const api = await getApi();
assert.notStrictEqual(doc.languageId, "markdown", "fixture is not markdown");
await vscode.commands.executeCommand("cowriting.openReviewPreview");
await settle();
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), false, "a non-markdown doc never enters coediting via this gateway");
});
test("explorer/context contributes the review-preview item for markdown only", () => {
const items = (pkg().contributes.menus["explorer/context"] ?? []) as Array<{ command: string; when?: string }>; const items = (pkg().contributes.menus["explorer/context"] ?? []) as Array<{ command: string; when?: string }>;
const entry = items.find((m) => m.command === "cowriting.showTrackChangesPreview"); const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
assert.ok(entry, "explorer/context has a showTrackChangesPreview entry"); assert.ok(entry, "explorer/context has an openReviewPreview entry");
assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown resources"); assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown resources");
}); });
test("editor/title/context (tab right-click) contributes the review-panel item for markdown only", () => { test("editor/title/context (tab right-click) contributes the review-preview item for markdown only", () => {
const items = (pkg().contributes.menus["editor/title/context"] ?? []) as Array<{ command: string; when?: string }>; const items = (pkg().contributes.menus["editor/title/context"] ?? []) as Array<{ command: string; when?: string }>;
const entry = items.find((m) => m.command === "cowriting.showTrackChangesPreview"); const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
assert.ok(entry, "editor/title/context has a showTrackChangesPreview entry"); assert.ok(entry, "editor/title/context has an openReviewPreview entry");
assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown tabs"); assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown tabs");
}); });
test("the command title reads 'Open Cowriting Review Panel'", () => { test("editor/title (toolbar icon) contributes the review-preview item for markdown only", () => {
const cmd = (pkg().contributes.commands as Array<{ command: string; title: string }>).find( const items = (pkg().contributes.menus["editor/title"] ?? []) as Array<{ command: string; when?: string }>;
(c) => c.command === "cowriting.showTrackChangesPreview", const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
); assert.ok(entry, "editor/title has an openReviewPreview entry");
assert.ok(cmd, "command is contributed"); assert.match(entry!.when ?? "", /editorLangId == markdown/, "gated on markdown");
assert.strictEqual(cmd!.title, "Open Cowriting Review Panel", "menus render this title");
}); });
test("the ctrl+alt+r keybinding still targets the command (unchanged)", () => { test("the command title reads 'Open Cowriting Review Preview'", () => {
const kb = (pkg().contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find( const cmd = (pkg().contributes.commands as Array<{ command: string; title: string }>).find(
(c) => c.command === "cowriting.openReviewPreview",
);
assert.ok(cmd, "command is contributed");
assert.strictEqual(cmd!.title, "Open Cowriting Review Preview", "menus render this title");
});
test("the old cowriting.showTrackChangesPreview command/menus/keybinding are fully gone (Task 8 exit criterion)", () => {
const p = pkg();
const commands = p.contributes.commands as Array<{ command: string }>;
assert.ok(!commands.some((c) => c.command === "cowriting.showTrackChangesPreview"), "command contribution removed");
for (const menuId of ["commandPalette", "editor/title", "editor/title/context", "explorer/context"]) {
const items = (p.contributes.menus[menuId] ?? []) as Array<{ command: string }>;
assert.ok(!items.some((m) => m.command === "cowriting.showTrackChangesPreview"), `${menuId} no longer references it`);
}
const kb = (p.contributes.keybindings as Array<{ command: string }>).find(
(k) => k.command === "cowriting.showTrackChangesPreview", (k) => k.command === "cowriting.showTrackChangesPreview",
); );
assert.ok(kb, "keybinding still present"); assert.ok(!kb, "keybinding removed");
assert.strictEqual(kb!.key, "ctrl+alt+r", "key unchanged"); });
test("ctrl+alt+r now targets cowriting.reviewChanges (the native diff), not the retired preview command", () => {
const kb = (pkg().contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find(
(k) => k.key === "ctrl+alt+r",
);
assert.ok(kb, "ctrl+alt+r keybinding still present");
assert.strictEqual(kb!.command, "cowriting.reviewChanges", "the chord now opens the native diff (Step 1 re-point)");
}); });
}); });
+8 -9
View File
@@ -3,6 +3,7 @@ import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import * as vscode from "vscode"; import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension"; import type { CowritingApi } from "../../../src/extension";
import { renderHtmlFor } from "./helpers";
const WS = process.env.E2E_WORKSPACE!; const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 400)); const settle = () => new Promise((r) => setTimeout(r, 400));
@@ -10,7 +11,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "exports preview controller"); assert.ok(api?.diffViewController && api?.attributionController, "exports diffView + attribution");
return api; return api;
} }
@@ -35,21 +36,19 @@ suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
test("pin clears authorship coloring; a later edit brings annotations back", async () => { test("pin clears authorship coloring; a later edit brings annotations back", async () => {
const { doc, key } = await freshDoc("docs/s48pin.md", "# S48\n\nAn original baseline paragraph.\n"); const { doc, key } = await freshDoc("docs/s48pin.md", "# S48\n\nAn original baseline paragraph.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// Type a paragraph → a human attribution span + author coloring in the on-state. // Type a paragraph → a human attribution span + author coloring in the on-state.
const edit = new vscode.WorkspaceEdit(); const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n"); edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied"); assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle(); await settle();
assert.match(ctl.renderHtmlFor(key), /cw-ins-human/, "typed text is author-colored cw-ins-human before the pin"); assert.match(renderHtmlFor(api, doc, key), /cw-ins-human/, "typed text is author-colored cw-ins-human before the pin");
// Pin the baseline → zero diff → the panel must be fully clean. // Pin the baseline (the real command — replaces the F11-webview's pinBaseline
ctl.receiveMessage(key, { type: "pinBaseline" }); // toolbar intent, Task 8) → zero diff → the render must be fully clean.
await vscode.commands.executeCommand("cowriting.markReviewed");
await settle(); await settle();
const pinned = ctl.renderHtmlFor(key); const pinned = renderHtmlFor(api, doc, key);
assert.ok(!pinned.includes("cw-ins-human"), "no human insertion marks after pin"); assert.ok(!pinned.includes("cw-ins-human"), "no human insertion marks after pin");
assert.ok(!pinned.includes("cw-ins-claude"), "no Claude insertion marks after pin"); assert.ok(!pinned.includes("cw-ins-claude"), "no Claude insertion marks after pin");
assert.ok(!pinned.includes("cw-ins-") && !pinned.includes("cw-del-"), "no insertion or deletion marks after pin"); assert.ok(!pinned.includes("cw-ins-") && !pinned.includes("cw-del-"), "no insertion or deletion marks after pin");
@@ -61,6 +60,6 @@ suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
edit2.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA second typed paragraph diverges again.\n"); edit2.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA second typed paragraph diverges again.\n");
assert.ok(await vscode.workspace.applyEdit(edit2), "second operator edit applied"); assert.ok(await vscode.workspace.applyEdit(edit2), "second operator edit applied");
await settle(); await settle();
assert.match(ctl.renderHtmlFor(key), /cw-ins-human/, "cw-ins-human authorship coloring returns once the doc diverges from the pin"); assert.match(renderHtmlFor(api, doc, key), /cw-ins-human/, "cw-ins-human authorship coloring returns once the doc diverges from the pin");
}); });
}); });
-140
View File
@@ -1,140 +0,0 @@
import * as assert from "assert";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
const WS = process.env.E2E_WORKSPACE!;
const DOC_REL = "docs/preview.md";
const docUri = () => vscode.Uri.file(path.join(WS, DOC_REL)).toString();
/** Open the doc AND enter coediting (INV-10/Task 2) — the baseline is
* established only on entry; re-entering an already-coedited doc is a no-op
* so the once-captured baseline survives across this order-dependent suite. */
async function openDoc(rel = DOC_REL): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, rel));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc;
}
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, "extension exports trackChangesPreviewController");
return api;
}
const settle = () => new Promise((r) => setTimeout(r, 400));
const kinds = (api: CowritingApi) =>
(api.trackChangesPreviewController.getLastModel(docUri()) ?? []).map((o) => o.kind);
// Order-dependent (F2F6 pattern): later tests consume earlier state.
suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, no LLM)", () => {
const TARGET = "A target sentence Claude will rewrite via the seam.";
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
test("open on a markdown doc → panel open, opened baseline shows no changes (PUC-1)", async () => {
const doc = await openDoc();
const api = await getApi();
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), false, "no panel yet");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), true, "panel open");
const model = api.trackChangesPreviewController.getLastModel(docUri());
assert.ok(model && model.length > 0, "a model was computed");
assert.ok(
model!.every((o) => o.kind === "unchanged"),
"baseline == buffer at open → every block unchanged",
);
void doc;
});
test("typing produces a changed/added block (PUC-2)", async () => {
const doc = await openDoc();
const api = await getApi();
const edit = new vscode.WorkspaceEdit();
const end = doc.positionAt(doc.getText().length);
edit.insert(doc.uri, end, "\n\nA freshly typed paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle();
assert.ok(
kinds(api).some((k) => k === "added" || k === "changed"),
"an added/changed block appears after typing",
);
});
test("accepting a proposal does NOT advance the baseline; the landed block stays marked (PUC-3, INV-7/D21, #48 retired)", async () => {
const doc = await openDoc();
const api = await getApi();
const start = doc.getText().indexOf(TARGET);
assert.ok(start >= 0, "fixture contains the target sentence");
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + TARGET.length,
newText: REPLACEMENT,
model: "sonnet",
sessionId: "e2e-f7",
turnId: "turn-f7-1",
});
assert.ok(id, "propose returns an id");
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle();
// Native-surfaces migration (INV-7/D21): the shipped machine-landing baseline
// advance (#48/INV-18) is retired — the landed block stays a visible change
// until "Mark Changes as Reviewed".
const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
const replacementMarked = model.some(
(o) => o.kind !== "unchanged" && o.block.raw.includes("CLAUDE REWROTE"),
);
assert.ok(replacementMarked, "the just-landed text still renders marked (baseline does not auto-advance)");
});
test("markReviewed resets the baseline to now → preview shows no marks (PUC-4)", async () => {
const doc = await openDoc();
const api = await getApi();
await vscode.commands.executeCommand("cowriting.markReviewed");
await settle();
assert.ok(
kinds(api).every((k) => k === "unchanged"),
"after markReviewed, every block is unchanged (baseline == buffer)",
);
void doc;
});
test("a non-markdown doc → command warns and opens no panel (PUC-6, markdown-only)", async () => {
const txt = await openDoc("docs/notes.txt");
const api = await getApi();
const key = txt.uri.toString();
assert.notStrictEqual(txt.languageId, "markdown", "fixture is not markdown");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for non-markdown");
});
test("a changed flowchart augments the emitted mermaid source (#22, INV-29)", async () => {
const doc = await openDoc();
const api = await getApi();
// Show the preview and pin the baseline so the fixture's `a --> b` flowchart
// is the "before"; then add an edge `a --> c` and confirm the intra-diagram diff.
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await vscode.commands.executeCommand("cowriting.markReviewed");
await settle();
const anchor = doc.getText().indexOf("a --> b");
assert.ok(anchor >= 0, "fixture contains the flowchart edge");
const insertAt = doc.positionAt(anchor + "a --> b".length);
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, insertAt, "\n a --> c");
assert.ok(await vscode.workspace.applyEdit(edit), "flowchart edit applied");
await settle();
const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
const mermaidOp = model.find((o) => o.kind === "changed" && o.block.type === "mermaid");
assert.ok(mermaidOp, "the flowchart block is a changed mermaid op");
const html = api.trackChangesPreviewController.renderHtmlFor(docUri());
assert.match(html, /classDef cwAdded/, "augmented with cwAdded classDef");
assert.match(html, /class\s+c\s+cwAdded/, "node c colored added");
assert.match(html, /cw-mermaid-legend/, "legend emitted");
});
});
+3 -2
View File
@@ -4,6 +4,7 @@ import * as path from "path";
import * as vscode from "vscode"; import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension"; import type { CowritingApi } from "../../../src/extension";
import { undoWorks, UNDO_SKIP_REASON } from "./undoCapable"; import { undoWorks, UNDO_SKIP_REASON } from "./undoCapable";
import { renderHtmlFor } from "./helpers";
const WS = process.env.E2E_WORKSPACE!; const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 400)); const settle = () => new Promise((r) => setTimeout(r, 400));
@@ -11,7 +12,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.attributionController && api?.trackChangesPreviewController, "exports attribution + preview"); assert.ok(api?.attributionController && api?.proposalController, "exports attribution + proposal");
return api; return api;
} }
@@ -83,7 +84,7 @@ suite("F10 #38 — undo does not mis-attribute restored text (host E2E, no LLM)"
// And the on-state render must not color 'bravo' as human-authored. // And the on-state render must not color 'bravo' as human-authored.
// Added blocks use cw-ins-human (not cw-by-human) since Task 3/44ef0a2. // Added blocks use cw-ins-human (not cw-by-human) since Task 3/44ef0a2.
const html = api.trackChangesPreviewController.renderHtmlFor(key); const html = renderHtmlFor(api, doc, key);
const bravoColoredHuman = /cw-ins-human[^<]*bravo|<[^>]+class="[^"]*cw-ins-human[^"]*"[^>]*>[^<]*bravo/.test(html); const bravoColoredHuman = /cw-ins-human[^<]*bravo|<[^>]+class="[^"]*cw-ins-human[^"]*"[^>]*>[^<]*bravo/.test(html);
assert.ok(!bravoColoredHuman, "restored 'bravo' is not colored cw-ins-human in the preview"); assert.ok(!bravoColoredHuman, "restored 'bravo' is not colored cw-ins-human in the preview");
}); });
+29
View File
@@ -43,4 +43,33 @@ describe("preview annotations (PUC-3/D21)", () => {
expect(html).toContain("cw-ins-claude"); expect(html).toContain("cw-ins-claude");
expect(html).not.toMatch(/[-]/); // no PUA sentinel leaks expect(html).not.toMatch(/[-]/); // no PUA sentinel leaks
}); });
// Task 8 (coverage moved from the deleted f10Review.test.ts PUC-3/9): a
// pending F4 proposal is optimistically applied in the buffer (F12, INV-48),
// so the preview renders its applied text ins-claude and reinserts the
// pre-apply original as a deletion — proposals are agent-authored by
// definition, so this is unconditional (no `spans` entry needed).
it("a pending proposal renders its applied text cw-ins-claude and the original as a deletion", () => {
const src = "hello CLAUDE world\n";
const html = render(src, {
...base,
baselineText: "hello world\n",
spans: [],
proposals: [
{ id: "p1", anchorStart: 6, anchorEnd: 12, replaced: "world", replacement: "CLAUDE" },
],
});
expect(html).toContain('class="cw-ins-claude"');
expect(html).toContain("CLAUDE");
expect(html).toContain("cw-del");
expect(html).toContain("world");
});
// Task 8 (INV-32/33 coverage moved from f10Review.test.ts): a block with no
// spans and no baseline divergence carries no cw- marks at all, even when
// annotations are enabled — "changed since baseline" is the only trigger.
it("an unchanged block (baseline == src, no spans) renders with no cw- marks", () => {
const html = render("hello unchanged world\n", {
...base, baselineText: "hello unchanged world\n", spans: [],
});
expect(html).not.toContain("cw-");
});
}); });