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:
@@ -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
|
||||
human/Claude attribution (Feature #6), F4 propose/accept diff flow
|
||||
(Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view
|
||||
toggle (Feature #17), F10 interactive review — **write left / review
|
||||
right** (Feature #29), and F11 — the **preview toolbar as the primary
|
||||
interaction surface** (Feature #43).
|
||||
data layer (Feature #17), and the **native-surfaces migration** (the
|
||||
"Coediting Markdown with a Machine" Solution Design) — the current UI, replacing
|
||||
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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
## 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
|
||||
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`.
|
||||
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
|
||||
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`.
|
||||
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 |
|
||||
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`.
|
||||
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
|
||||
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`.
|
||||
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.
|
||||
Beside the existing **Annotations** switch it gains two controls:
|
||||
|
||||
+5
-21
@@ -30,29 +30,15 @@ const liveTurnOptions = {
|
||||
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} */
|
||||
const previewMermaidOptions = {
|
||||
entryPoints: ["media/preview-mermaid.ts"],
|
||||
outfile: "out/media/preview-mermaid.js",
|
||||
bundle: true,
|
||||
// Task 7 (Q4): contributed via markdown.previewScripts into the BUILT-IN
|
||||
// preview's webview — same browser/IIFE shape as previewOptions above, and
|
||||
// the same reason mermaid is bundled here (never in the host bundle).
|
||||
// preview's webview (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).
|
||||
platform: "browser",
|
||||
format: "iife",
|
||||
target: "es2020",
|
||||
@@ -63,16 +49,14 @@ const previewMermaidOptions = {
|
||||
if (watch) {
|
||||
const ctx = await context(options);
|
||||
const ctxLive = await context(liveTurnOptions);
|
||||
const ctxPreview = await context(previewOptions);
|
||||
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…");
|
||||
} else {
|
||||
await build(options);
|
||||
await build(liveTurnOptions);
|
||||
await build(previewOptions);
|
||||
await build(previewMermaidOptions);
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
@@ -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
@@ -109,8 +109,8 @@
|
||||
"icon": "$(git-compare)"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"title": "Open Cowriting Review Panel",
|
||||
"command": "cowriting.openReviewPreview",
|
||||
"title": "Open Cowriting Review Preview",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
@@ -252,7 +252,7 @@
|
||||
"group": "navigation@4"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"command": "cowriting.openReviewPreview",
|
||||
"when": "editorLangId == markdown",
|
||||
"group": "navigation@9"
|
||||
}
|
||||
@@ -264,14 +264,14 @@
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"command": "cowriting.openReviewPreview",
|
||||
"when": "resourceLangId == markdown",
|
||||
"group": "1_cowriting@3"
|
||||
}
|
||||
],
|
||||
"explorer/context": [
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"command": "cowriting.openReviewPreview",
|
||||
"when": "resourceLangId == markdown",
|
||||
"group": "navigation@9"
|
||||
}
|
||||
@@ -330,10 +330,10 @@
|
||||
},
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"command": "cowriting.reviewChanges",
|
||||
"key": "ctrl+alt+r",
|
||||
"mac": "cmd+alt+r",
|
||||
"when": "editorLangId == markdown"
|
||||
"when": "editorLangId == markdown && cowriting.isCoediting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
|
||||
+10
-103
@@ -1,28 +1,22 @@
|
||||
/**
|
||||
* EditFlow — the host edit-turn flow (F11/F12), extracted from the review
|
||||
* webview controller ahead of its sunset (native-surfaces migration, spec
|
||||
* §6.10). Owns the instruction prompt + progress-wrapped "ask Claude" gesture
|
||||
* (INV-10 gate, INV-8 host-only LLM surface), and the low-level turn→proposal(s)
|
||||
* cut (`runEditAndPropose`, INV-39/40). Never mutates the document directly —
|
||||
* every turn lands as one or more F4 proposals via `ProposalController.propose`
|
||||
* (INV-10). `runEditAndPropose` is reachable from the review webview (via this
|
||||
* class's `askClaude`) and, from Task 6 on, the thread controller
|
||||
* (`ThreadController.runMakeEdit`) — vscode-API-only, no webview state. The
|
||||
* `cowriting.editDocument` command lives in extension.ts (Finding-1 fix) so it
|
||||
* can hand off to `ThreadController.askClaude()`.
|
||||
* §6.10). Owns the low-level turn→proposal(s) cut (`runEditAndPropose`,
|
||||
* INV-39/40): never mutates the document directly — every turn lands as one or
|
||||
* more F4 proposals via `ProposalController.propose` (INV-10). Reachable, from
|
||||
* Task 6 on, from the thread controller (`ThreadController.runMakeEdit`) —
|
||||
* vscode-API-only, no webview state. The `cowriting.editDocument` command lives
|
||||
* in extension.ts (Finding-1 fix) so it can hand off to
|
||||
* `ThreadController.askClaude()`. Task 8 deleted this class's own
|
||||
* `askClaude`/`askEditInstruction` (the review webview's toolbar ask) once
|
||||
* their only caller — the webview — was sunset alongside them.
|
||||
*/
|
||||
import * as vscode from "vscode";
|
||||
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 { buildFingerprint } from "./anchorer";
|
||||
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).
|
||||
* 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");
|
||||
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. */
|
||||
private turnSeq = 0;
|
||||
private nextTurnSeq(): number {
|
||||
return ++this.turnSeq;
|
||||
}
|
||||
|
||||
constructor(
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
constructor(private readonly proposals: ProposalController) {}
|
||||
|
||||
/**
|
||||
* F11/F12 (INV-35/39): run one host edit turn and record the result as F4
|
||||
|
||||
@@ -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[] {
|
||||
if (document.languageId !== "markdown") return [];
|
||||
if (!this.registry.isCoediting(document.uri)) return [];
|
||||
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[] = [];
|
||||
for (const v of this.proposals.listProposals(document)) {
|
||||
if (v.anchorStart === null || !this.proposals.isApplied(key, v.id)) continue;
|
||||
const pos = document.positionAt(v.anchorStart);
|
||||
if (applied.length >= 2) {
|
||||
const top = new vscode.Range(0, 0, 0, 0);
|
||||
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);
|
||||
lenses.push(
|
||||
new vscode.CodeLens(line, { title: "Accept ▾", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }),
|
||||
new vscode.CodeLens(line, { title: "Reject ▾", command: "cowriting.proposalRejectMenu", 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] }),
|
||||
);
|
||||
}
|
||||
return lenses;
|
||||
|
||||
+43
-34
@@ -11,7 +11,6 @@ import { GlobalSidecarStore } from "./globalSidecarStore";
|
||||
import { SidecarRouter } from "./sidecarRouter";
|
||||
import { DiffViewController } from "./diffViewController";
|
||||
import { GitBaselineAdapter } from "./gitBaseline";
|
||||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||||
import { EditFlow } from "./editFlow";
|
||||
import { LiveProgressUi } from "./liveProgressUi";
|
||||
import { EditorProposalController } from "./editorProposalController";
|
||||
@@ -29,7 +28,6 @@ export interface CowritingApi {
|
||||
proposalController: ProposalController;
|
||||
versionGuard: VersionGuard;
|
||||
diffViewController: DiffViewController;
|
||||
trackChangesPreviewController: TrackChangesPreviewController;
|
||||
editFlow: EditFlow;
|
||||
sidecarRouter: SidecarRouter;
|
||||
liveProgressUi: LiveProgressUi;
|
||||
@@ -160,8 +158,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry);
|
||||
context.subscriptions.push(attributionController);
|
||||
|
||||
// --- F4: propose/accept (Feature #12) — constructed before the preview so F10
|
||||
// can route ✓/✗ through it ---
|
||||
// --- F4: propose/accept (Feature #12) ---
|
||||
const proposalController = new ProposalController(
|
||||
sidecarRouter,
|
||||
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
|
||||
// `cowriting.editDocument` command, the instruction-prompt/progress-wrapped
|
||||
// "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut
|
||||
// (`runEditAndPropose`, INV-39/40). Constructed BEFORE the review preview (which
|
||||
// delegates to it) and BEFORE the thread controller, which now consumes it too
|
||||
// (Task 6: the comment-loop's "make this edit" offer calls runEditAndPropose). ---
|
||||
const editFlow = new EditFlow(proposalController, attributionController, liveProgressUi, coeditingRegistry);
|
||||
// (`runEditAndPropose`, INV-39/40). Constructed BEFORE the thread controller,
|
||||
// which consumes it too (Task 6: the comment-loop's "make this edit" offer
|
||||
// calls runEditAndPropose). ---
|
||||
const editFlow = new EditFlow(proposalController);
|
||||
context.subscriptions.push(editFlow);
|
||||
|
||||
// --- 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);
|
||||
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,
|
||||
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
|
||||
const editorProposalController = new EditorProposalController(
|
||||
@@ -211,10 +194,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
context.subscriptions.push(editorProposalController);
|
||||
|
||||
// #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
|
||||
// through its own webview-facing acceptAll). Reuses the batched F4 seam +
|
||||
// reports applied-vs-skipped (native-surfaces migration: routes directly to
|
||||
// ProposalController, no preview-controller indirection).
|
||||
// (also reachable from the top-of-file "Keep all (N)" CodeLens, Task 8 PUC-2).
|
||||
// Reuses the batched F4 seam + reports applied-vs-skipped, routing directly to
|
||||
// ProposalController (native-surfaces migration: no preview-controller
|
||||
// indirection).
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => {
|
||||
const doc = vscode.window.activeTextEditor?.document;
|
||||
@@ -223,7 +206,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
return;
|
||||
}
|
||||
const { applied, skipped } = await proposalController.acceptAllProposals(doc);
|
||||
trackChangesPreviewController.refresh(doc);
|
||||
if (applied === 0 && skipped === 0) return;
|
||||
const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : "";
|
||||
void vscode.window.showInformationMessage(
|
||||
@@ -242,7 +224,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
return;
|
||||
}
|
||||
const { reverted } = await proposalController.rejectAll(doc);
|
||||
trackChangesPreviewController.refresh(doc);
|
||||
if (reverted > 0) {
|
||||
void vscode.window.showInformationMessage(
|
||||
`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`
|
||||
// used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompts via
|
||||
// `askEditInstruction` — a rejecting stub since Task 6 sunset the input webview
|
||||
// (spec §6.10). Reached with no 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
|
||||
// used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompted via
|
||||
// `askEditInstruction` — a rejecting stub after Task 6 sunset the input webview
|
||||
// (spec §6.10; both `askClaude`/`askEditInstruction` were deleted from EditFlow in
|
||||
// Task 8 once their only caller, the review webview, died too). Reached with no
|
||||
// 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
|
||||
// active editor), focuses it, and hands off to the SAME comments-first ask as
|
||||
// `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.
|
||||
// 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
|
||||
@@ -456,7 +466,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
proposalController,
|
||||
versionGuard,
|
||||
diffViewController,
|
||||
trackChangesPreviewController,
|
||||
editFlow,
|
||||
sidecarRouter,
|
||||
liveProgressUi,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { renderHtmlFor } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
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> {
|
||||
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");
|
||||
assert.ok(api?.attributionController && api?.proposalController, "exports attribution + proposal");
|
||||
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,
|
||||
// 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
|
||||
// baseline (see diffView.test.ts / trackChangesPreview.test.ts / baselineRouter.test.ts
|
||||
// for the post-accept baseline behavior, INV-7/D21). Owns its own markdown doc.
|
||||
// baseline (see diffView.test.ts / baselineRouter.test.ts for the post-accept
|
||||
// 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)", () => {
|
||||
const DOC_REL = "docs/f10claude.md";
|
||||
const TARGET = "The sentence Claude will compose over.";
|
||||
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);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
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 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)
|
||||
const start = doc.getText().indexOf(TARGET);
|
||||
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.
|
||||
// After accepting, the baseline advances (INV-18) → the text becomes "unchanged"
|
||||
// and renders plain — so we check BEFORE accepting.
|
||||
const pendingHtml = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
const pendingHtml = renderHtmlFor(api, doc, key);
|
||||
assert.ok(
|
||||
pendingHtml.includes("cw-ins-claude"),
|
||||
"pending Claude proposal block carries cw-ins-claude in the on-state render",
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.diffViewController, "exports preview + diffView");
|
||||
assert.ok(api?.editFlow && api?.diffViewController, "exports editFlow + diffView");
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -29,36 +29,15 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
// F11 host E2E (no LLM): the preview toolbar is the primary interaction surface.
|
||||
// The webview posts intent messages; the host routes them through the existing
|
||||
// F4/F6/F3 seams (INV-35). The webview DOM (real button clicks) is sealed and
|
||||
// manual-smoke only; here we simulate the inbound messages via `receiveMessage`.
|
||||
suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () => {
|
||||
// SLICE-1: the Pin baseline button.
|
||||
test("pinBaseline message pins the PREVIEWED doc → marks clear, baseline reason is pinned (PUC-5, INV-35)", async () => {
|
||||
const { doc, key } = await freshDoc("docs/f11pin.md", "# F11 pin\n\nA baseline paragraph that will diverge.\n");
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
|
||||
|
||||
// Diverge from the opened baseline so the preview carries a real change-mark.
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed paragraph that diverges.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||
await settle();
|
||||
const marked = (api.trackChangesPreviewController.getLastModel(key) ?? []).some((o) => o.kind !== "unchanged");
|
||||
assert.ok(marked, "the typed paragraph shows as a change before pinning");
|
||||
|
||||
// Simulate the webview's Pin baseline button posting its intent.
|
||||
api.trackChangesPreviewController.receiveMessage(key, { type: "pinBaseline" });
|
||||
await settle();
|
||||
|
||||
const model = api.trackChangesPreviewController.getLastModel(key) ?? [];
|
||||
assert.ok(model.length > 0 && model.every((o) => o.kind === "unchanged"), "after pin, every block is unchanged");
|
||||
assert.strictEqual(api.diffViewController.getBaseline(key)?.reason, "pinned", "baseline reason advanced to pinned");
|
||||
});
|
||||
|
||||
// F11/F12 host E2E (no LLM): EditFlow.runEditAndPropose is the turn→proposal(s)
|
||||
// cut shared by every Ask-Claude entry point (comments-first ask, editDocument,
|
||||
// editSelection). Originally exercised via the now-sunset review webview's
|
||||
// toolbar (Task 8 deleted that webview and its message-routing seam,
|
||||
// `receiveMessage`/`pinBaseline` webview-message coverage duplicates
|
||||
// diffView.test.ts / baselineRouter.test.ts's `markReviewed` tests) — these
|
||||
// tests drive `runEditAndPropose` directly, the same programmatic seam the
|
||||
// deleted toolbar itself called into.
|
||||
suite("F11/F12 — EditFlow document/selection edit turns (host E2E, no LLM)", () => {
|
||||
// SLICE-1 reachability: the orphaned pin command gets a real palette `when`.
|
||||
// Renamed cowriting.pinDiffBaseline → cowriting.markReviewed (Task 2, D14).
|
||||
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 editFlow = api.editFlow;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
// Stub the host edit turn (no LLM in CI): rewrite two distinct words.
|
||||
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 block proposal carries the whole rewritten paragraph",
|
||||
);
|
||||
// F12 (INV-48): EditorProposalController optimistically applies the proposed text,
|
||||
// so after settle the buffer has the replacement. The proposal is still pending.
|
||||
// F12 (INV-48): EditorProposalController optimistically applies the proposed
|
||||
// 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");
|
||||
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 api = await getApi();
|
||||
const editFlow = api.editFlow;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
const target = "The target paragraph Claude will rewrite.";
|
||||
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 api = await getApi();
|
||||
const editFlow = api.editFlow;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const target = "Leave me exactly as I am.";
|
||||
const start = doc.getText().indexOf(target);
|
||||
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 api = await getApi();
|
||||
const editFlow = api.editFlow;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
|
||||
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.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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 controllers");
|
||||
assert.ok(api?.editFlow && api?.proposalController, "exports controllers");
|
||||
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 () => {
|
||||
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 { doc, key } = await freshDoc("docs/f12-all.md", original);
|
||||
const { doc } = await freshDoc("docs/f12-all.md", original);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
const editFlow = api.editFlow;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 3, "three changed blocks → three pending proposals");
|
||||
|
||||
// Simulate the toolbar "Accept all" button posting its intent.
|
||||
ctl.receiveMessage(key, { type: "acceptAll" });
|
||||
// Route through the real "Keep all" command (Task 8, PUC-2) — the doc is the
|
||||
// active editor from freshDoc, matching the top-of-file CodeLens's own target.
|
||||
await vscode.commands.executeCommand("cowriting.acceptAllProposals");
|
||||
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 api = await getApi();
|
||||
const editFlow = api.editFlow;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" }));
|
||||
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");
|
||||
});
|
||||
|
||||
// A single pending proposal still applies through the batch path (the button is
|
||||
// hidden < 2 pending in the webview, but the command/seam handle any count).
|
||||
// A single pending proposal still applies through the batch path (the
|
||||
// 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 () => {
|
||||
const { doc } = await freshDoc("docs/f12-one.md", "# One\n\nThe only paragraph here.\n");
|
||||
const api = await getApi();
|
||||
const editFlow = api.editFlow;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# One\n\nThe ONLY paragraph here.\n",
|
||||
model: "sonnet",
|
||||
|
||||
@@ -12,7 +12,7 @@ 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 controllers");
|
||||
assert.ok(api?.diffViewController && api?.proposalController, "exports controllers");
|
||||
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 () => {
|
||||
const { doc, key } = await freshDoc("docs/f12-land.md", "# T\n\nalpha here.\n");
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const before = api.diffViewController.getBaseline(key)?.text ?? doc.getText();
|
||||
const start = doc.getText().indexOf("alpha");
|
||||
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)", () => {
|
||||
test("reject from the webview 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");
|
||||
test("reject-one via revertInPlace reverts in place; rejectAll clears every proposal", async () => {
|
||||
const { doc } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
const p = api.proposalController;
|
||||
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" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||
await settle(); await settle();
|
||||
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"));
|
||||
// reject ONE via the webview intent → that block reverts, the other stays applied
|
||||
ctl.receiveMessage(key, { type: "reject", proposalId: ids[0] });
|
||||
// reject ONE via the same seam the per-proposal "✗ Reject" CodeLens uses
|
||||
// (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();
|
||||
assert.ok(doc.getText().includes("uno aaa.") && doc.getText().includes("BBB"), "one reverted, one applied");
|
||||
// rejectAll via the command → all gone, document restored
|
||||
ctl.receiveMessage(key, { type: "rejectAll" });
|
||||
// rejectAll via the same seam the top-of-file "✗ Reject all" CodeLens uses →
|
||||
// all gone, document restored.
|
||||
const { reverted } = await p.rejectAll(doc);
|
||||
assert.strictEqual(reverted, 1, "the one remaining proposal reverted");
|
||||
await settle(); await settle();
|
||||
assert.strictEqual(doc.getText(), "# P\n\nuno aaa.\n\ndos bbb.\n", "document restored");
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0);
|
||||
|
||||
@@ -10,7 +10,7 @@ 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, "exports preview controller");
|
||||
assert.ok(api?.threadController, "exports thread controller");
|
||||
return api;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 controllers");
|
||||
assert.ok(api?.proposalController && api?.editorProposalController, "exports controllers");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
// 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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import * as assert from "node:assert";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { renderReview } from "../../../src/trackChangesModel";
|
||||
|
||||
/** Activate the extension and return its exported API (asserts it is real). */
|
||||
export async function activateApi(): Promise<CowritingApi> {
|
||||
@@ -15,6 +16,25 @@ export async function activateApi(): Promise<CowritingApi> {
|
||||
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. */
|
||||
export function settle(): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, 150));
|
||||
|
||||
@@ -10,7 +10,7 @@ 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, "exports preview controller");
|
||||
assert.ok(api?.editFlow && api?.proposalController, "exports edit flow + proposal controller");
|
||||
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 api = await getApi();
|
||||
const editFlow = api.editFlow;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
// The stub honors opts.onProgress (emitting synthetic snapshots) but returns
|
||||
// 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 api = await getApi();
|
||||
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
|
||||
// failed to thread through runEditAndPropose, the stub would instead return a
|
||||
|
||||
@@ -10,7 +10,7 @@ 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, "exports preview controller");
|
||||
assert.ok(api?.coeditingRegistry, "exports coeditingRegistry");
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -26,14 +26,19 @@ function pkg(): any {
|
||||
return JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||
}
|
||||
|
||||
// #41 (story): "Open Cowriting Review Panel" reachable from the markdown
|
||||
// file/tab right-click menu. The review surface (showTrackChangesPreview) is the
|
||||
// plugin's central affordance; this adds explorer/context + editor/title/context
|
||||
// entry points and makes the command open the *clicked* document — not merely
|
||||
// the active editor — so the explorer right-click works even when the file is
|
||||
// not already open.
|
||||
suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-doc resolution)", () => {
|
||||
test("command with a Uri previews the CLICKED doc, not the active editor", async () => {
|
||||
// Task 8 (native-surfaces migration, spec §6.10): the review-webview's #41
|
||||
// right-click entries ("Open Cowriting Review Panel") are replaced by
|
||||
// `cowriting.openReviewPreview` — a minimal wrapper that resolves the clicked
|
||||
// document (or falls back to the active editor, mirroring #41's original
|
||||
// behavior), enters coediting if not already (so there is a baseline to review
|
||||
// against), and hands off to VS Code's OWN "Open Preview to the Side"
|
||||
// (`markdown.showPreviewToSide`) — the built-in preview IS the review surface
|
||||
// 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.
|
||||
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");
|
||||
@@ -43,19 +48,20 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview", bUri);
|
||||
await vscode.commands.executeCommand("cowriting.openReviewPreview", bUri);
|
||||
await settle();
|
||||
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(bUri.toString()), true, "preview opened for the clicked doc");
|
||||
assert.strictEqual(
|
||||
api.trackChangesPreviewController.isOpen(aUri.toString()),
|
||||
false,
|
||||
"the active editor's doc did NOT get a preview",
|
||||
);
|
||||
// `markdown.showPreviewToSide` itself ends with the preview WEBVIEW focused
|
||||
// (not a text editor), so `activeTextEditor` is not a stable post-command
|
||||
// signal — `isCoediting` (set by the command's OWN resolve-and-enter step,
|
||||
// before it hands off to the preview) is: the clicked doc B entered, the
|
||||
// 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");
|
||||
});
|
||||
|
||||
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.
|
||||
const uri = writeFile("docs/menu-unopened.md", "# Unopened\n\nNever opened before the right-click.\n");
|
||||
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",
|
||||
);
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview", uri);
|
||||
await vscode.commands.executeCommand("cowriting.openReviewPreview", uri);
|
||||
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 () => {
|
||||
@@ -78,39 +88,74 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await vscode.commands.executeCommand("cowriting.openReviewPreview");
|
||||
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 entry = items.find((m) => m.command === "cowriting.showTrackChangesPreview");
|
||||
assert.ok(entry, "explorer/context has a showTrackChangesPreview entry");
|
||||
const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
|
||||
assert.ok(entry, "explorer/context has an openReviewPreview entry");
|
||||
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 entry = items.find((m) => m.command === "cowriting.showTrackChangesPreview");
|
||||
assert.ok(entry, "editor/title/context has a showTrackChangesPreview entry");
|
||||
const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
|
||||
assert.ok(entry, "editor/title/context has an openReviewPreview entry");
|
||||
assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown tabs");
|
||||
});
|
||||
|
||||
test("the command title reads 'Open Cowriting Review Panel'", () => {
|
||||
const cmd = (pkg().contributes.commands as Array<{ command: string; title: string }>).find(
|
||||
(c) => c.command === "cowriting.showTrackChangesPreview",
|
||||
);
|
||||
assert.ok(cmd, "command is contributed");
|
||||
assert.strictEqual(cmd!.title, "Open Cowriting Review Panel", "menus render this title");
|
||||
test("editor/title (toolbar icon) contributes the review-preview item for markdown only", () => {
|
||||
const items = (pkg().contributes.menus["editor/title"] ?? []) as Array<{ command: string; when?: string }>;
|
||||
const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
|
||||
assert.ok(entry, "editor/title has an openReviewPreview entry");
|
||||
assert.match(entry!.when ?? "", /editorLangId == markdown/, "gated on markdown");
|
||||
});
|
||||
|
||||
test("the ctrl+alt+r keybinding still targets the command (unchanged)", () => {
|
||||
const kb = (pkg().contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find(
|
||||
test("the command title reads 'Open Cowriting Review Preview'", () => {
|
||||
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",
|
||||
);
|
||||
assert.ok(kb, "keybinding still present");
|
||||
assert.strictEqual(kb!.key, "ctrl+alt+r", "key unchanged");
|
||||
assert.ok(!kb, "keybinding removed");
|
||||
});
|
||||
|
||||
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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { renderHtmlFor } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
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> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
const { doc, key } = await freshDoc("docs/s48pin.md", "# S48\n\nAn original baseline paragraph.\n");
|
||||
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.
|
||||
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();
|
||||
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.
|
||||
ctl.receiveMessage(key, { type: "pinBaseline" });
|
||||
// Pin the baseline (the real command — replaces the F11-webview's pinBaseline
|
||||
// toolbar intent, Task 8) → zero diff → the render must be fully clean.
|
||||
await vscode.commands.executeCommand("cowriting.markReviewed");
|
||||
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-claude"), "no Claude insertion 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");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit2), "second operator edit applied");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (F2–F6 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");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { undoWorks, UNDO_SKIP_REASON } from "./undoCapable";
|
||||
import { renderHtmlFor } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
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> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
// 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);
|
||||
assert.ok(!bravoColoredHuman, "restored 'bravo' is not colored cw-ins-human in the preview");
|
||||
});
|
||||
|
||||
@@ -43,4 +43,33 @@ describe("preview annotations (PUC-3/D21)", () => {
|
||||
expect(html).toContain("cw-ins-claude");
|
||||
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-");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user