Native surfaces migration — evolve the extension onto VS Code's native review surfaces (9-task plan, spec v0.2.1) #72

Merged
benstull merged 18 commits from session-0065 into main 2026-07-02 23:09:38 +00:00
54 changed files with 3689 additions and 1995 deletions
+89 -8
View File
@@ -14,9 +14,59 @@ catalog (a pure, key-free SDK call) in a notification and the
Features shipped so far: F2 region-anchored threads (Feature #4), F3 live
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:
@@ -863,7 +863,9 @@ git add src/ package.json test/
git commit -m "feat: gate every surface on CoeditingRegistry (INV-10) — commenting ranges re-assigned on gate change (spec §6.4 v0.2.1)"
```
---### Task 5: Extract the edit flow from the webview controller
---
### Task 5: Extract the edit flow from the webview controller
**Files:**
- Create: `src/editFlow.ts`
@@ -1227,7 +1229,7 @@ git commit -m "feat!: sunset the review-panel webview — built-in preview + nat
- Create: `test/e2e/suite/fullLoop.test.ts`, `scripts/smoke-native-loop.mjs`
- Modify: `package.json` (script `smoke:native`)
- [ ] **Step 1: The named full loop (§6.8), stub-turned:** one E2E that walks PUC-7 → PUC-8 → PUC-2 → PUC-1 in a single doc: enter (snapshot) → thread ask (stub turn) → offer → pending proposals decorated (assert `listProposals` + `isApplied`) → tweak by typing inside a pending range (assert re-anchor: proposal still resolvable) → Keep one (assert attribution split via `spansFor`: claude words + human tweak) → Reject one (assert buffer text equals pre-proposal text for that range **exactly** — INV-5) → `cowriting.markReviewed` → change count 0.
- [ ] **Step 1: The named full loop (§6.8), stub-turned:** one E2E that walks PUC-7 → PUC-8 → PUC-2 → PUC-1 in a single doc: enter (snapshot) → thread ask (stub turn) → offer → pending proposals decorated (assert `listProposals` + `isApplied`) → tweak by typing inside a pending range (assert the documented anchoring contract: the exact-substring anchor orphans — INV-1/INV-11 — but the proposal is NOT lost: it stays listed and Keep still lands it, because `finalizeInPlace` looks up by id and bypasses resolution by design) → Keep one (assert attribution split via `spansFor`: claude words + human tweak) → Reject one (assert buffer text equals pre-proposal text for that range **exactly** — INV-5; note: the reject candidate must be un-tweaked — reject-after-interior-edit is a known pre-existing F12 INV-5 gap, tracked as a follow-up issue) → `cowriting.markReviewed` → change count 0. *(Corrected during execution: the original text asserted "re-anchor: proposal still resolvable" after an interior tweak, which contradicts the shipped exact-substring anchorer contract.)*
- [ ] **Step 2: Live smoke (rung 3 — the real SDK, manual gate):** `scripts/smoke-native-loop.mjs` mirrors `scripts/smoke-live-turn.mjs` but through the comment loop: launches the EDH, enters a sandbox doc, posts a real comment, waits for the real `@cline/sdk` reply + offer, accepts, prints the created proposal ids. `npm run smoke:native`. This is operator-run (real tokens); the plan's definition of done for rung 3 is one green run reported in the session transcript.
- [ ] **Step 3: Verify + commit.**
+13 -10
View File
@@ -31,13 +31,14 @@ const liveTurnOptions = {
};
/** @type {import('esbuild').BuildOptions} */
const previewOptions = {
entryPoints: ["media/preview.ts"],
outfile: "out/media/preview.js",
const previewMermaidOptions = {
entryPoints: ["media/preview-mermaid.ts"],
outfile: "out/media/preview-mermaid.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.
// Task 7 (Q4): contributed via markdown.previewScripts into the BUILT-IN
// 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",
@@ -48,12 +49,14 @@ const previewOptions = {
if (watch) {
const ctx = await context(options);
const ctxLive = await context(liveTurnOptions);
const ctxPreview = await context(previewOptions);
await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch()]);
const ctxPreviewMermaid = await context(previewMermaidOptions);
await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreviewMermaid.watch()]);
console.log("esbuild: watching…");
} else {
await build(options);
await build(liveTurnOptions);
await build(previewOptions);
console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js");
await build(previewMermaidOptions);
console.log(
"esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview-mermaid.js",
);
}
+55
View File
@@ -0,0 +1,55 @@
/*
* Task 7 (D3/D21, PUC-3) — authorship + change annotations for the BUILT-IN VS
* Code Markdown preview (`markdown.previewStyles`). Same F10 vocabulary as the
* (sunsetting) custom webview's `media/preview.css`: style = operation
* (underline = inserted, strikethrough = removed), color = author (human
* green, Claude blue) — `cw-del` has no author variant (a single fixed
* struck-red mark; see `previewAnnotations.ts`/`trackChangesModel.ts`'s "del"
* sentinel tag). Light-theme defaults below; `body.vscode-dark` (also applied
* on high-contrast dark) overrides with the same palette the webview preview
* already ships, for continuity across both review surfaces.
*/
.cw-ins-claude {
background: rgba(9, 105, 218, 0.12);
border-bottom: 2px solid #0969da;
text-decoration: none;
}
.cw-ins-human {
background: rgba(26, 127, 55, 0.12);
border-bottom: 2px solid #1a7f37;
text-decoration: none;
}
.cw-del {
background: rgba(207, 34, 46, 0.1);
text-decoration: line-through;
text-decoration-color: #cf222e;
opacity: 0.75;
}
body.vscode-dark .cw-ins-claude,
body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-ins-claude {
background: rgba(88, 166, 255, 0.15);
border-bottom-color: #58a6ff;
}
body.vscode-dark .cw-ins-human,
body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-ins-human {
background: rgba(63, 185, 80, 0.14);
border-bottom-color: #3fb950;
}
body.vscode-dark .cw-del,
body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-del {
background: rgba(248, 81, 73, 0.11);
text-decoration-color: #f85149;
}
/*
* F7.1 (#22) intra-diagram mermaid diff legend, shown beneath a mermaid fence
* `previewAnnotations.ts` re-emitted through `mermaidDiff.ts` (Task 7 §2.6
* parity — same legend markup + colors as the sunset webview's
* `media/preview.css`).
*/
.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; }
+57
View File
@@ -0,0 +1,57 @@
/**
* Task 7 (Q4 mermaid check, D3/D21): the built-in VS Code Markdown preview does
* not render mermaid fences on its own — `previewAnnotations.ts`'s
* `cowritingMarkdownItPlugin` teaches ```mermaid fences to render as
* `<pre class="mermaid">SRC</pre>` (via `options.highlight`, the same
* extension point the built-in preview's own fence rule already calls); this
* script (contributed via `markdown.previewScripts`) is what actually turns
* those into diagrams — mermaid needs a DOM, so it runs here, in the preview's
* webview, never in the extension host. Bundled by esbuild as a standalone
* IIFE → out/media/preview-mermaid.js, so mermaid never enters the host
* bundle (matching the sealed webview's `media/preview.ts` precedent).
*
* Contributed preview scripts are reloaded on every content change (VS Code
* docs), so running at module top-level is sufficient; the
* `vscode.markdown.updateContent` listener is added defensively to also cover
* in-place content updates that don't reload the script (mirrors the proven
* `bierner.markdown-mermaid` extension's own approach).
*
* RESOLVED SCOPE NOTE (Q4 finding, migration plan Task 7 Step 2.6; wired in a
* cross-task review follow-up): this script itself only turns `pre.mermaid`
* nodes into diagrams — it never sees a diff. The intra-diagram diff/legend
* augmentation (F7.1, INV-29..31) happens one step earlier, source-side,
* where it CAN be unit-tested without a DOM: `previewAnnotations.ts`'s
* `cowritingMarkdownItPlugin` re-emits a CHANGED mermaid fence's source
* through `mermaidDiff.ts` (`buildMermaidQueue`, block-level `diffBlocks`
* pairing against the F6/F7 baseline) before this script ever runs, so the
* `pre.mermaid` node this script hands to `mermaid.run` already carries the
* `classDef`/`class`/`linkStyle` styling directives — this script needn't (and
* can't, DOM-less-ly) know a diagram changed at all. Basic mermaid rendering
* (this file) is exercised by the same proven pattern as the real
* `bierner.markdown-mermaid` extension; the augmentation upstream is exercised
* by pure unit tests (`test/previewAnnotations.test.ts`).
*/
import mermaid from "mermaid";
function theme(): "dark" | "default" {
return document.body.classList.contains("vscode-dark") || document.body.classList.contains("vscode-high-contrast")
? "dark"
: "default";
}
async function run(): Promise<void> {
const nodes = Array.from(document.querySelectorAll<HTMLElement>("pre.mermaid"));
if (nodes.length === 0) return;
mermaid.initialize({ startOnLoad: false, theme: theme(), 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("vscode.markdown.updateContent", () => void run());
void run();
-118
View File
@@ -1,118 +0,0 @@
/* F7 track-changes preview — theme-aware via VS Code webview CSS variables. */
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
padding: 0 1.2rem 2rem;
line-height: 1.5;
}
#cw-header {
position: sticky;
top: 0;
background: var(--vscode-editor-background);
border-bottom: 1px solid var(--vscode-panel-border);
padding: 0.5rem 0;
font-size: 0.85em;
opacity: 0.85;
display: flex;
gap: 1rem;
}
/* Author-colored track changes — style = operation, color = author.
underline = inserted, strikethrough = removed; human green/red, Claude blue/purple. */
#cw-summary .cw-add { color: #3fb950; }
#cw-summary .cw-del { color: #f85149; }
.cw-blk { position: relative; }
/* base ins/del carry the operation; author classes carry the color */
ins { text-decoration: none; }
del { text-decoration: line-through; opacity: 0.7; }
.cw-ins-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
.cw-ins-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
.cw-ins-none { background: var(--vscode-diffEditor-insertedTextBackground, rgba(63,185,80,0.14)); text-decoration: none; }
.cw-del-human { background: rgba(248,81,73,0.11); text-decoration: line-through; text-decoration-color: #f85149; }
.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; }
.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; }
/* Task 8 — overlap: if a future render path nests cw-ins-{A} inside cw-del-{B},
both marks must show. cw-ins-* sets text-decoration:none which would otherwise
suppress the parent del's strikethrough. `inherit` restores the parent's
line-through while the child's border-bottom underline is unaffected.
The engine currently emits SIBLING ins/del (never nested) so this rule is a
forward defensive guarantee only. See task-8-report.md for details. */
.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude { text-decoration: inherit; }
/* whole-block ops: an added block is an insertion (its author runs are emitted as
cw-ins-* via colorByAuthor with kind="ins"); a standalone removed block is neutral
struck (adjacency heuristic fallback) */
.cw-added { background: transparent; }
.cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.10)); text-decoration: line-through; opacity: 0.65; }
.cw-changed { outline: none; }
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
#cw-summary .cw-prop { opacity: 0.85; }
.cw-badge {
position: absolute;
top: 0;
right: 0;
font-size: 0.7em;
padding: 0 0.4em;
border-radius: 3px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
}
.cw-error {
border: 1px solid var(--vscode-inputValidation-errorBorder);
background: var(--vscode-inputValidation-errorBackground);
color: var(--vscode-errorForeground);
padding: 0.3rem 0.6rem;
border-radius: 3px;
font-size: 0.85em;
}
pre.mermaid { text-align: center; background: transparent; }
pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
/* F11 — preview toolbar buttons (Pin baseline; adaptive Ask Claude). Theme-aware. */
#cw-header button {
cursor: pointer;
font: inherit;
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 3px;
padding: 0.1em 0.55em;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
#cw-header button:hover:not(:disabled) { background: var(--vscode-button-secondaryHoverBackground); }
#cw-header button:disabled { opacity: 0.5; cursor: default; }
/* F10 interactive review — annotations toggle + ✓/✗ proposal blocks. */
#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; }
.cw-proposal {
position: relative;
border-left: 3px solid var(--vscode-panel-border, #555);
background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent);
padding: 0.4em 0.6em; margin: 0.4em 0; border-radius: 3px;
}
.cw-proposal-unanchored { border-left-style: dashed; opacity: 0.85; }
.cw-actions { position: absolute; top: 0.2em; right: 0.4em; display: inline-flex; gap: 0.25em; }
.cw-actions button {
cursor: pointer; border: 1px solid var(--vscode-button-border, transparent);
border-radius: 3px; font-size: 0.9em; line-height: 1; padding: 0.1em 0.35em;
background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground);
}
.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
.cw-btngroup { display: inline-flex; }
.cw-btngroup .cw-caret { border-left: none; padding: 0.1em 0.25em; }
.cw-actions .cw-accept { font-weight: 600; }
.cw-accept:hover, .cw-btngroup:has(.cw-accept) .cw-caret:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover, .cw-btngroup:has(.cw-reject) .cw-caret:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
/* F7.1 (#22) intra-diagram mermaid diff legend. */
.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; }
.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; }
.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; }
.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; }
-157
View File
@@ -1,157 +0,0 @@
/**
* F7 preview webview client (sealed sandbox, INV-21). Receives annotated HTML
* from the extension host and swaps it in; runs mermaid over `.mermaid` blocks
* (mermaid needs a DOM, so it runs here, not in the host). Bundled by esbuild as
* a standalone IIFE → out/media/preview.js, so mermaid never enters the host
* bundle. No network, no LLM.
*/
import mermaid from "mermaid";
// Imported so esbuild emits the sibling out/media/preview.css (the controller
// links it into the sealed shell via asWebviewUri).
import "./preview.css";
declare function acquireVsCodeApi(): { postMessage(m: unknown): void };
interface RenderMessage {
type: "render";
mode: "on" | "off";
html: string;
epoch?: string;
summary?: { added: number; removed: number; proposals: number };
/** F11: false on a non-authorable doc → Pin + Ask-Claude controls disabled. */
authorable?: boolean;
}
const vscodeApi = acquireVsCodeApi();
const body = document.getElementById("cw-body")!;
const header = document.getElementById("cw-epoch")!;
const summary = document.getElementById("cw-summary")!;
const legend = document.getElementById("cw-legend")!;
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null;
const askEl = document.getElementById("cw-ask") as HTMLButtonElement | null;
const acceptAllEl = document.getElementById("cw-acceptall") as HTMLButtonElement | null;
// F10: the annotations on/off toggle.
annotationsEl?.addEventListener("change", () => {
vscodeApi.postMessage({ type: "setMode", mode: annotationsEl.checked ? "on" : "off" });
});
// F11 (SLICE-1): Pin baseline — post intent; the host pins via the F6 store (INV-35).
pinEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "pinBaseline" });
});
// F11 (SLICE-4): the single adaptive Ask-Claude button. Its label flips on
// `selectionchange` (Edit Selection when live text is selected in the preview,
// Edit Document otherwise), and a click resolves the selection to a SOURCE range
// via the nearest `data-src` ancestors (INV-36) — the webview's sole mapping
// duty. A selection that resolves to no live block falls back to document scope.
/** Walk up from a DOM node to the nearest block carrying data-src offsets (INV-36). */
function nearestSrc(node: Node | null): HTMLElement | null {
let el: HTMLElement | null = node instanceof HTMLElement ? node : (node?.parentElement ?? null);
while (el && el !== body) {
if (el.dataset.srcStart !== undefined && el.dataset.srcEnd !== undefined) return el;
el = el.parentElement;
}
return null;
}
/** The source [start,end) union of the live blocks a non-empty body selection touches, or null. */
function selectionSrcRange(): { start: number; end: number } | null {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
const ends = [nearestSrc(sel.anchorNode), nearestSrc(sel.focusNode)].filter(
(e): e is HTMLElement => e !== null,
);
if (ends.length === 0) return null; // selection touches no live-source block
const starts = ends.map((e) => Number(e.dataset.srcStart));
const stops = ends.map((e) => Number(e.dataset.srcEnd));
return { start: Math.min(...starts), end: Math.max(...stops) };
}
function updateAskLabel(): void {
if (!askEl) return;
askEl.textContent = selectionSrcRange()
? "✦ Ask Claude to Edit Selection"
: "✦ Ask Claude to Edit Document";
}
document.addEventListener("selectionchange", updateAskLabel);
askEl?.addEventListener("click", () => {
const range = selectionSrcRange();
if (range) {
vscodeApi.postMessage({ type: "askClaude", scope: "selection", start: range.start, end: range.end });
} else {
vscodeApi.postMessage({ type: "askClaude", scope: "document" });
}
});
// #46 (INV-42): Accept all — batch-accept every pending proposal (intent only).
acceptAllEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "acceptAll" });
});
// F12/#64: Accept/Reject (+ caret → accept-all/reject-all) on a proposal block.
body.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
if (!btn) return;
const id = btn.closest<HTMLElement>(".cw-proposal")?.dataset.proposalId;
const action = btn.dataset.action;
if (action === "acceptAll") return void vscodeApi.postMessage({ type: "acceptAll" });
if (action === "rejectAll") return void vscodeApi.postMessage({ type: "rejectAll" });
if (id && (action === "accept" || action === "reject")) {
vscodeApi.postMessage({ type: action, proposalId: id });
}
});
function themeFor(): "dark" | "default" {
return document.body.classList.contains("vscode-dark") ||
document.body.classList.contains("vscode-high-contrast")
? "dark"
: "default";
}
async function renderMermaid(): Promise<void> {
const nodes = Array.from(body.querySelectorAll<HTMLElement>("pre.mermaid"));
if (nodes.length === 0) return;
mermaid.initialize({ startOnLoad: false, theme: themeFor(), securityLevel: "strict" });
try {
await mermaid.run({ nodes });
} catch {
// mermaid.run already marks failed nodes; ensure a visible chip per failure.
for (const n of nodes) {
if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true");
}
}
}
window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
const msg = event.data;
if (msg?.type !== "render") return;
body.innerHTML = msg.html;
updateAskLabel(); // new content clears any selection → reset the adaptive label
// F11 (PUC-1/7): disable edit controls on a non-authorable doc (reading stays on).
const authorable = msg.authorable !== false;
if (pinEl) pinEl.disabled = !authorable;
if (askEl) askEl.disabled = !authorable;
const on = msg.mode === "on";
if (annotationsEl) annotationsEl.checked = on;
// #46: Accept all shows only with ≥2 pending proposals, on an authorable doc,
// in the annotated (on) state — a single proposal is just a ✓ in place.
if (acceptAllEl) acceptAllEl.hidden = !on || !authorable || (msg.summary?.proposals ?? 0) < 2;
// Off-state is a clean preview: hide the review chrome.
header.hidden = !on;
summary.hidden = !on;
legend.hidden = true;
if (on) {
header.textContent = `Review since ${msg.epoch ?? ""}`;
summary.innerHTML =
`<span class="cw-add">+${msg.summary?.added ?? 0}</span> ` +
`<span class="cw-del">${msg.summary?.removed ?? 0}</span> ` +
`<span class="cw-prop">${msg.summary?.proposals ?? 0} proposal${(msg.summary?.proposals ?? 0) === 1 ? "" : "s"}</span>`;
}
void renderMermaid();
});
+125 -21
View File
@@ -18,6 +18,13 @@
"onStartupFinished"
],
"contributes": {
"markdown.markdownItPlugins": true,
"markdown.previewStyles": [
"./media/preview-annotations.css"
],
"markdown.previewScripts": [
"./out/media/preview-mermaid.js"
],
"configuration": {
"title": "Cowriting",
"properties": {
@@ -25,6 +32,11 @@
"type": "boolean",
"default": true,
"description": "When Claude is editing, reveal the \"Cowriting: Claude\" output channel (without stealing focus) as soon as Claude starts producing text, so you can read the output as it streams."
},
"cowriting.annotations": {
"type": "boolean",
"default": true,
"description": "Show authorship + change annotations in the Markdown preview for coedited documents."
}
}
},
@@ -85,13 +97,20 @@
"category": "Cowriting"
},
{
"command": "cowriting.pinDiffBaseline",
"title": "Cowriting: Pin Review Baseline to Now",
"category": "Cowriting"
"command": "cowriting.markReviewed",
"title": "Mark Changes as Reviewed",
"category": "Cowriting",
"icon": "$(check-all)"
},
{
"command": "cowriting.showTrackChangesPreview",
"title": "Open Cowriting Review Panel",
"command": "cowriting.reviewChanges",
"title": "Review Changes",
"category": "Cowriting",
"icon": "$(git-compare)"
},
{
"command": "cowriting.openReviewPreview",
"title": "Open Cowriting Review Preview",
"category": "Cowriting"
},
{
@@ -118,6 +137,34 @@
"command": "cowriting.proposalRejectMenu",
"title": "Reject Claude Proposal",
"category": "Cowriting"
},
{
"command": "cowriting.coeditDocument",
"title": "✦ Coedit this Document with Claude",
"category": "Cowriting"
},
{
"command": "cowriting.stopCoediting",
"title": "Stop editing with Claude",
"category": "Cowriting"
},
{
"command": "cowriting.askClaude",
"title": "Ask Claude",
"category": "Cowriting",
"icon": "$(sparkle)"
},
{
"command": "cowriting.makeThreadEdit",
"title": "✦ Make this edit",
"category": "Cowriting",
"icon": "$(sparkle)"
},
{
"command": "cowriting.toggleAnnotations",
"title": "Toggle Annotations",
"category": "Cowriting",
"icon": "$(eye)"
}
],
"menus": {
@@ -139,8 +186,12 @@
"when": "false"
},
{
"command": "cowriting.pinDiffBaseline",
"when": "editorLangId == markdown"
"command": "cowriting.reviewChanges",
"when": "editorLangId == markdown && cowriting.isCoediting"
},
{
"command": "cowriting.markReviewed",
"when": "editorLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot"
},
{
"command": "cowriting.edit",
@@ -154,13 +205,25 @@
"command": "cowriting.editDocument",
"when": "false"
},
{
"command": "cowriting.askClaude",
"when": "editorLangId == markdown && cowriting.isCoediting"
},
{
"command": "cowriting.toggleAnnotations",
"when": "editorLangId == markdown && cowriting.isCoediting"
},
{
"command": "cowriting.makeThreadEdit",
"when": "false"
},
{
"command": "cowriting.acceptAllProposals",
"when": "editorLangId == markdown"
"when": "editorLangId == markdown && cowriting.isCoediting"
},
{
"command": "cowriting.rejectAllProposals",
"when": "editorLangId == markdown"
"when": "editorLangId == markdown && cowriting.isCoediting"
},
{
"command": "cowriting.proposalAcceptMenu",
@@ -173,7 +236,27 @@
],
"editor/title": [
{
"command": "cowriting.showTrackChangesPreview",
"command": "cowriting.reviewChanges",
"when": "resourceLangId == markdown && cowriting.isCoediting",
"group": "navigation@1"
},
{
"command": "cowriting.askClaude",
"when": "resourceLangId == markdown && cowriting.isCoediting",
"group": "navigation@2"
},
{
"command": "cowriting.toggleAnnotations",
"when": "resourceLangId == markdown && cowriting.isCoediting",
"group": "navigation@3"
},
{
"command": "cowriting.markReviewed",
"when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot",
"group": "navigation@4"
},
{
"command": "cowriting.openReviewPreview",
"when": "editorLangId == markdown",
"group": "navigation@9"
}
@@ -181,18 +264,18 @@
"editor/title/context": [
{
"command": "cowriting.edit",
"when": "resourceLangId == markdown",
"when": "resourceLangId == markdown && cowriting.isCoediting",
"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"
}
@@ -200,47 +283,67 @@
"editor/context": [
{
"command": "cowriting.edit",
"when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
"when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting",
"group": "1_cowriting@1"
},
{
"command": "cowriting.createThread",
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)",
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting",
"group": "1_cowriting@2"
},
{
"command": "cowriting.coeditDocument",
"when": "editorLangId == markdown && !cowriting.isCoediting",
"group": "1_cowriting@1"
},
{
"command": "cowriting.stopCoediting",
"when": "editorLangId == markdown && cowriting.isCoediting",
"group": "1_cowriting@9"
}
],
"comments/commentThread/context": [
{
"command": "cowriting.reply",
"group": "inline",
"group": "inline@1",
"when": "commentController == cowriting.threads"
},
{
"command": "cowriting.makeThreadEdit",
"group": "inline@2",
"when": "commentController == cowriting.threads && commentThread =~ /\\boffer\\b/"
}
],
"comments/commentThread/title": [
{
"command": "cowriting.makeThreadEdit",
"group": "inline@1",
"when": "commentController == cowriting.threads && commentThread =~ /\\boffer\\b/"
},
{
"command": "cowriting.resolveThread",
"group": "inline",
"when": "commentController == cowriting.threads && commentThread =~ /^open$/"
"when": "commentController == cowriting.threads && commentThread =~ /\\bopen\\b/"
},
{
"command": "cowriting.reopenThread",
"group": "inline",
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
"when": "commentController == cowriting.threads && commentThread =~ /\\bresolved\\b/"
}
]
},
"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",
"key": "ctrl+alt+e",
"mac": "cmd+alt+e",
"when": "editorTextFocus && editorLangId == markdown"
"when": "editorTextFocus && editorLangId == markdown && cowriting.isCoediting"
}
]
},
@@ -253,6 +356,7 @@
"pretest:e2e": "npm run build && npm run clean:e2e && tsc -p tsconfig.e2e.json",
"test:e2e": "node ./out/test/e2e/runTest.js",
"smoke:live": "npm run build && node scripts/smoke-live-turn.mjs",
"smoke:native": "npm run pretest:e2e && node scripts/smoke-native-loop.mjs",
"vscode:prepublish": "node esbuild.mjs"
},
"dependencies": {
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// Task 9 / spec §7.1 rung 3: the manual, real-@cline/sdk smoke for the native
// comment loop -- mirrors scripts/smoke-live-turn.mjs's role for F3 (a live
// login-backed check, deliberately NOT in CI), but this one drives the whole
// EDH (comment -> reply -> offer -> proposal needs vscode's own comment/doc
// APIs, unlike smoke-live-turn.mjs's bare module call) via
// test/e2e/smoke/nativeLoop.ts, launched exactly like test/e2e/runTest.ts
// launches the regular host-E2E suites -- just against a single smoke entry
// point that is NOT swept into `npm run test:e2e` (it lives outside
// test/e2e/suite, which is the only directory suite/index.ts's mocha glob
// searches).
//
// Prereqs: `npm run pretest:e2e` (builds the extension + compiles
// test/e2e/**). `npm run smoke:native` does this for you.
//
// Opens the real sandbox/ folder (the repo's EDH workspace root), writes a
// throwaway doc, posts a real comment via ThreadController.createThreadOnSelection,
// waits for the real @cline/sdk reply + offer, makes + accepts the edit, then
// prints the created proposal ids and deletes the throwaway doc.
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { runTests } from "@vscode/test-electron";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function main() {
const projectRoot = path.resolve(__dirname, "..");
const extensionDevelopmentPath = projectRoot;
const extensionTestsPath = path.resolve(projectRoot, "out/test/e2e/smoke/nativeLoop");
const sandbox = path.resolve(projectRoot, "sandbox");
if (!fs.existsSync(`${extensionTestsPath}.js`)) {
console.error(
`smoke-native-loop: ${extensionTestsPath}.js is missing -- run "npm run pretest:e2e" first (or "npm run smoke:native", which does this for you).`,
);
process.exit(1);
}
// Same macOS UNIX-socket-length workaround as test/e2e/runTest.ts (a long
// worktree path + the default .vscode-test/user-data dir can blow past the
// ~103-char limit).
const userDataRoot = process.platform === "win32" ? os.tmpdir() : "/tmp";
const userDataDir = fs.mkdtempSync(path.join(userDataRoot, "cwud-smoke-"));
try {
await runTests({
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: [sandbox, "--disable-extensions", "--user-data-dir", userDataDir],
});
console.log("smoke-native-loop: PASS");
process.exit(0);
} catch (err) {
console.error(
`smoke-native-loop: FAILED (expected when Claude Code is absent/signed out, or the sandbox/ folder is missing): ${
err instanceof Error ? err.message : String(err)
}`,
);
process.exit(1);
} finally {
fs.rmSync(userDataDir, { recursive: true, force: true });
}
}
main();
+39 -7
View File
@@ -21,6 +21,7 @@ import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath";
import type { AuthorSpan } from "./trackChangesModel";
import type { CoeditingRegistry } from "./coeditingRegistry";
/** Test-facing snapshot of live attribution state for a document. */
export interface RenderedSpan {
@@ -56,8 +57,11 @@ export class AttributionController implements vscode.Disposable {
/**
* F6 (§6.2/§6.4): the single machine-landing signal. Fired after a real
* (non-no-op) seam apply succeeds. INV-9 makes the seam the sole machine-edit
* ingress, so this is the sole signal — DiffViewController subscribes to
* advance the baseline; no call-site wiring, no future driver can forget it.
* ingress, so this is the sole signal. Native-surfaces migration (INV-7/
* INV-18 retirement, spec §6.4): DiffViewController no longer subscribes
* here to advance the baseline — a landed edit stays a visible
* change-since-baseline until commit (head mode) or "Mark Changes as
* Reviewed" (snapshot mode). Other consumers may still subscribe.
*/
private readonly applyEmitter = new vscode.EventEmitter<{ document: vscode.TextDocument }>();
readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event;
@@ -66,12 +70,35 @@ export class AttributionController implements vscode.Disposable {
private readonly store: SidecarRouter,
private readonly rootDir: string | undefined,
private readonly guard: VersionGuard,
private readonly registry: CoeditingRegistry,
) {
this.disposables.push(this.statusItem, this.output, this.applyEmitter);
this.disposables.push(
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
vscode.window.onDidChangeActiveTextEditor(() => this.renderActive()),
// PUC-7 restore-on-enter: `loadAll` is the only path that (re)populates
// s.spans/s.orphans from the sidecar — without this, a document's FIRST
// entry into coediting (registry.enter(), extension.ts) never runs it,
// so pre-existing committed authorship never surfaces until the next
// edit. Mirrors ThreadController's own registry subscription; `loadAll`
// already gates on isCoediting, and exit intentionally does nothing here
// (no data to wipe — EditorProposalController's own gate clears the
// decorations that read spansFor). Registered before ProposalController/
// EditorProposalController (construction order in extension.ts) so their
// renders see fresh spans on the same enter transition.
this.registry.onDidChange(({ uri, coediting }) => {
if (!coediting) {
// Finding 5 (final whole-branch review): exit hides the status-bar
// item too — otherwise a stale "N orphaned attributions" warning
// for a doc that's no longer even coediting stays pinned in the bar
// until some unrelated render happens to fire for it.
if (vscode.window.activeTextEditor?.document.uri.toString() === uri) this.statusItem.hide();
return;
}
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) this.loadAll(doc);
}),
);
}
@@ -101,6 +128,7 @@ export class AttributionController implements vscode.Disposable {
/** Load the sidecar and re-resolve every attribution (live span | orphan). */
loadAll(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return;
if (!this.registry.isCoediting(document.uri)) return;
const docPath = this.keyOf(document);
const s = this.state(docPath);
const artifact = this.store.load(docPath);
@@ -142,6 +170,7 @@ export class AttributionController implements vscode.Disposable {
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
if (!this.registry.isCoediting(e.document.uri)) return;
const docPath = this.keyOf(e.document);
if (!e.document.isDirty && this.matchesDisk(e.document)) {
// Disk sync (revert / external reload): buffer now equals the file on
@@ -291,9 +320,10 @@ export class AttributionController implements vscode.Disposable {
);
}
if (ok && opts?.landBaseline !== false) {
// F6 (INV-18): a real machine landing — advance the baseline. F12 (INV-48)
// suppresses this for optimistic apply: the proposed text is in the buffer
// but the change stays PENDING (baseline at pre-proposal) until accept.
// F6 (INV-18, retired as a baseline advance — spec §6.4/INV-7): fire the
// machine-landing signal. F12 (INV-48) suppresses this for optimistic
// apply: the proposed text is in the buffer but the change stays PENDING
// until accept.
this.applyEmitter.fire({ document });
}
return ok;
@@ -302,8 +332,10 @@ export class AttributionController implements vscode.Disposable {
/**
* F12/#64 (INV-51): fire the machine-landing signal WITHOUT applying text — used
* by finalize-in-place, where the proposed text already landed in the buffer via
* optimistic apply (`landBaseline:false`) and accept only needs to advance the
* F6 baseline so the now-accepted change stops reading as pending.
* optimistic apply (`landBaseline:false`) and accept marks the landing so the
* now-accepted change is recorded (INV-7: the baseline itself no longer
* advances on landing — it advances only on commit / "Mark Changes as
* Reviewed").
*/
signalLanded(document: vscode.TextDocument): void {
if (this.isTracked(document)) this.applyEmitter.fire({ document });
+17 -2
View File
@@ -7,11 +7,19 @@
* key is a hash of the document URI (the controller derives it), not a
* workspace-relative path; an untitled buffer has no durable identity and is
* never persisted here (in-memory only — see DiffViewController).
*
* INV-7 (native-surfaces migration, spec §6.4): the baseline is now either the
* git-HEAD blob (never persisted — see GitBaselineAdapter/DiffViewController)
* or a snapshot captured on coediting-entry / re-pinned by "Mark Changes as
* Reviewed". `"opened"`/`"machine-landing"` are legacy reasons from the
* shipped #17/#48 baseline; `normalizeReason` migrates them on load so old
* on-disk baselines keep round-tripping under the new vocabulary.
*/
import * as fs from "node:fs";
import * as path from "node:path";
export type BaselineReason = "opened" | "machine-landing" | "pinned";
export type BaselineReason = "entered" | "pinned" | "head";
type LegacyBaselineReason = BaselineReason | "opened" | "machine-landing";
export interface Baseline {
/** the document URI (the identity the key is derived from) — for round-trip/debug. */
@@ -22,6 +30,12 @@ export interface Baseline {
reason: BaselineReason;
}
/** Migrate legacy on-disk reasons to the current vocabulary (§6.4 INV-7). */
export function normalizeReason(reason: LegacyBaselineReason): BaselineReason {
if (reason === "opened" || reason === "machine-landing") return "entered";
return reason;
}
export class BaselineStore {
/** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */
constructor(private readonly storageDir: string) {}
@@ -34,7 +48,8 @@ export class BaselineStore {
load(key: string): Baseline | null {
const p = this.baselinePath(key);
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, "utf8")) as Baseline;
const raw = JSON.parse(fs.readFileSync(p, "utf8")) as Baseline;
return { ...raw, reason: normalizeReason(raw.reason) };
}
/** Newest epoch wins — overwrite in place, no history (state-not-history, the F3/F4 precedent). */
+57
View File
@@ -0,0 +1,57 @@
/**
* CoeditingRegistry — the INV-10 activation gate (spec §6.4). Holds the set of
* documents the writer has explicitly entered into coediting; every surface
* (SCM/QuickDiff, comments, preview annotations, edit commands, decorations)
* checks membership before attaching. Persisted in workspaceState so a reload
* restores the set (the durable sidecar carries the artifacts; this carries
* only the mode).
*/
import * as vscode from "vscode";
const STATE_KEY = "cowriting.coeditingSet";
export class CoeditingRegistry implements vscode.Disposable {
private readonly set: Set<string>;
private readonly emitter = new vscode.EventEmitter<{ uri: string; coediting: boolean }>();
readonly onDidChange = this.emitter.event;
/** Set by the SCM surface (Task 3) so syncContext can expose the baseline mode. */
baselineModeOf: ((uri: vscode.Uri) => "head" | "snapshot" | undefined) | undefined;
constructor(private readonly state: vscode.Memento) {
this.set = new Set(state.get<string[]>(STATE_KEY, []));
}
enter(uri: vscode.Uri): void {
const key = uri.toString();
if (this.set.has(key)) return;
this.set.add(key);
void this.state.update(STATE_KEY, [...this.set]);
this.emitter.fire({ uri: key, coediting: true });
}
exit(uri: vscode.Uri): void {
const key = uri.toString();
if (!this.set.delete(key)) return;
void this.state.update(STATE_KEY, [...this.set]);
this.emitter.fire({ uri: key, coediting: false });
}
isCoediting(uri: vscode.Uri): boolean {
return this.set.has(uri.toString());
}
list(): string[] {
return [...this.set];
}
syncContext(editor: vscode.TextEditor | undefined): void {
const coediting = !!editor && this.isCoediting(editor.document.uri);
void vscode.commands.executeCommand("setContext", "cowriting.isCoediting", coediting);
const mode = coediting && editor ? this.baselineModeOf?.(editor.document.uri) : undefined;
void vscode.commands.executeCommand("setContext", "cowriting.baselineMode", mode ?? "");
}
dispose(): void {
this.emitter.dispose();
}
}
+126 -43
View File
@@ -1,9 +1,16 @@
/**
* DiffViewController — F6 baseline data layer (spec §6.4/§6.7). Owns the baseline
* lifecycle (initialize at first sight / advance at every machine landing / pin
* on demand) and serves it to F7/F10 via `getBaseline` + the additive
* `onDidChangeBaseline` event. A pure data layer: never mutates the document,
* sidecar, or attribution state (INV-19).
* DiffViewController — the baseline router (native-surfaces migration, spec
* §6.4, INV-7). Resolves each coedited document's review baseline to one of two
* modes: **head** (git-tracked — the baseline is the `HEAD` blob, re-read on
* every commit, never persisted) or **snapshot** (untracked/no-repo — captured
* on coediting-entry, re-pinned on demand by "Mark Changes as Reviewed").
* Baselines are established ONLY for documents the CoeditingRegistry has
* entered (INV-10) — opening a file no longer captures one (the retired
* `ensureBaseline`/#17 behavior). The shipped machine-landing advance (#48,
* INV-18) is retired too: a landed Claude edit stays a visible change-since-
* baseline until the file is committed (head mode) or reviewed (snapshot
* mode) — spec INV-7's note, D21. A pure data layer: never mutates the
* document, sidecar, or attribution state (INV-19).
*
* The F6 two-pane `vscode.diff` *view* (toggle UI + the `cowriting-baseline:`
* virtual document) was removed in #34 once F10 made the rendered preview the
@@ -12,37 +19,57 @@
* The baseline works on ANY text document, not just workspace files — it needs no
* `.threads/` sidecar, only a stable doc identity + a storage home. So it has
* its OWN diffable predicate (any `file:` or `untitled:` doc), decoupled from
* F2's workspace `isTracked`. Persistable docs (`file:`) keep their baseline in
* VS Code's per-extension GLOBAL storage keyed by a hash of the document URI;
* untitled buffers have no durable identity, so their baseline is in-memory
* only (lost on reload) — the same degrade the storage-unavailable path uses.
* F2's workspace `isTracked`. Persistable docs (`file:`) keep their SNAPSHOT
* baseline in VS Code's per-extension GLOBAL storage keyed by a hash of the
* document URI (INV-7: head-mode baselines are storage-free — never
* persisted); untitled buffers have no durable identity, so their baseline is
* in-memory only (lost on reload) — the same degrade the storage-unavailable
* path uses.
*/
import { createHash } from "node:crypto";
import * as vscode from "vscode";
import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore";
import type { GitBaselineAdapter } from "./gitBaseline";
import type { CoeditingRegistry } from "./coeditingRegistry";
export class DiffViewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
/** Source of truth for the baseline, keyed by `document.uri.toString()`. */
private readonly baselines = new Map<string, Baseline>();
/** F7 (additive): fires on every baseline capture (open / advance / pin) so
* the track-changes preview refreshes without polling. Carries the real
* document URI. */
/** Which mode each established document resolved to (INV-7). */
private readonly modes = new Map<string, "head" | "snapshot">();
/** F7 (additive): fires on every baseline capture (establish / head-refresh /
* pin) so the track-changes preview refreshes without polling. Carries the
* real document URI. */
private readonly onDidChangeBaselineEmitter = new vscode.EventEmitter<{ uri: string }>();
readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event;
private storageWarned = false;
constructor(private readonly store: BaselineStore | null) {
constructor(
private readonly store: BaselineStore | null,
private readonly git: GitBaselineAdapter,
registry: CoeditingRegistry,
) {
this.disposables.push(
this.onDidChangeBaselineEmitter,
vscode.commands.registerCommand("cowriting.pinDiffBaseline", () =>
vscode.commands.registerCommand("cowriting.markReviewed", () =>
this.pinCommand(vscode.window.activeTextEditor),
),
// F6 captures a baseline for any diffable doc the moment it is first seen,
// independent of the workspace gate (so "opened" is the open-time text).
vscode.workspace.onDidOpenTextDocument((d) => this.ensureBaseline(d)),
// INV-10: a baseline is established only once its document ENTERS
// coediting; exiting keeps the stored baseline as-is (PUC-7).
registry.onDidChange(({ uri, coediting }) => {
if (!coediting) return;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) void this.establish(doc);
}),
// A commit on a watched repo may advance a head-mode baseline (D13).
git.onDidChangeHead(() => void this.refreshHeadBaselines()),
);
for (const d of vscode.workspace.textDocuments) this.ensureBaseline(d);
// Restore already-coediting documents (e.g. after an extension host reload).
for (const key of registry.list()) {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
if (doc) void this.establish(doc);
}
}
// ---- diffability / identity --------------------------------------------------------
@@ -51,7 +78,7 @@ export class DiffViewController implements vscode.Disposable {
private isDiffable(document: vscode.TextDocument): boolean {
return document.uri.scheme === "file" || document.uri.scheme === "untitled";
}
/** Only `file:` docs have a durable identity to persist a baseline against. */
/** Only `file:` docs have a durable identity to persist a SNAPSHOT baseline against. */
private isPersistable(document: vscode.TextDocument): boolean {
return document.uri.scheme === "file";
}
@@ -64,36 +91,66 @@ export class DiffViewController implements vscode.Disposable {
return createHash("sha256").update(uriKey).digest("hex");
}
// ---- baseline lifecycle (§6.4) -----------------------------------------------------
// ---- baseline lifecycle (§6.4, INV-7) -----------------------------------------------
/** First sight of a diffable doc: load the stored baseline, else capture `opened`. */
ensureBaseline(document: vscode.TextDocument): void {
/**
* Resolve this document's baseline mode and (re)establish its baseline.
* git-tracked (a HEAD blob resolves) → **head** mode, baseline = HEAD, never
* persisted. Otherwise → **snapshot** mode: load a previously-stored
* snapshot, else capture the buffer now (`"entered"`). Called once when a
* document enters coediting (registry.enter) and at startup for documents
* already coediting.
*/
async establish(document: vscode.TextDocument): Promise<void> {
if (!this.isDiffable(document)) return;
const key = this.uriKey(document);
if (this.baselines.has(key)) return;
const head = await this.git.headFor(document.uri);
if (head) {
this.modes.set(key, "head");
this.setBaseline(key, head.text, "head");
return;
}
this.modes.set(key, "snapshot");
if (this.store && this.isPersistable(document)) {
try {
const stored = this.store.load(this.storageKey(key));
if (stored) {
this.baselines.set(key, stored);
return;
}
} catch {
this.warnStorageOnce();
const stored = this.tryLoad(key); // existing load path, legacy reasons normalized
if (stored) {
this.baselines.set(key, stored);
this.fire(key);
return;
}
}
this.capture(document, "opened");
this.capture(document, "entered");
}
/** Machine landing (INV-18): re-capture so landed text never shows as a change. */
advance(document: vscode.TextDocument): void {
if (!this.isDiffable(document)) return;
this.capture(document, "machine-landing");
/** The mode a document's baseline resolved to, or undefined before `establish`. */
modeOf(uriString: string): "head" | "snapshot" | undefined {
return this.modes.get(uriString);
}
/** Human pin: baseline := now; the preview's change-marks empty (left = right). */
/** Re-read HEAD for every head-mode document; a commit advances the baseline (D13). */
private async refreshHeadBaselines(): Promise<void> {
for (const [key, mode] of this.modes) {
if (mode !== "head") continue;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
if (!doc) continue;
const head = await this.git.headFor(doc.uri);
if (head && head.text !== this.baselines.get(key)?.text) {
this.setBaseline(key, head.text, "head"); // commit advanced the baseline (D13)
}
}
}
/** Human pin ("Mark Changes as Reviewed", D14): snapshot mode only — a
* git-tracked (head-mode) baseline advances only by commit. */
pin(document: vscode.TextDocument): void {
if (!this.isDiffable(document)) return;
const key = this.uriKey(document);
if (this.modes.get(key) !== "snapshot") {
void vscode.window.showWarningMessage(
"Cowriting: this document is git-tracked — commit to advance the baseline.",
);
return;
}
this.capture(document, "pinned");
}
@@ -103,12 +160,38 @@ export class DiffViewController implements vscode.Disposable {
const baseline: Baseline = { uri: key, text: document.getText(), capturedAt: new Date().toISOString(), reason };
this.baselines.set(key, baseline);
if (this.store && this.isPersistable(document)) {
try {
this.store.save(this.storageKey(key), baseline);
} catch {
this.warnStorageOnce();
}
this.trySave(key, baseline);
}
this.fire(key);
}
/** Set a baseline from resolved text directly (head mode — no document buffer read). */
private setBaseline(key: string, text: string, reason: BaselineReason): void {
const baseline: Baseline = { uri: key, text, capturedAt: new Date().toISOString(), reason };
this.baselines.set(key, baseline);
// head-mode baselines are storage-free (INV-7); persist only snapshots.
if (reason !== "head" && this.store) this.trySave(key, baseline);
this.fire(key);
}
private tryLoad(key: string): Baseline | null {
try {
return this.store!.load(this.storageKey(key));
} catch {
this.warnStorageOnce();
return null;
}
}
private trySave(key: string, baseline: Baseline): void {
try {
this.store!.save(this.storageKey(key), baseline);
} catch {
this.warnStorageOnce();
}
}
private fire(key: string): void {
this.onDidChangeBaselineEmitter.fire({ uri: key });
}
@@ -122,7 +205,7 @@ export class DiffViewController implements vscode.Disposable {
private pinCommand(editor: vscode.TextEditor | undefined): void {
if (!editor || !this.isDiffable(editor.document)) {
void vscode.window.showWarningMessage("Cowriting: focus a text editor to pin its review baseline.");
void vscode.window.showWarningMessage("Cowriting: focus a text editor to mark its changes as reviewed.");
return;
}
this.pin(editor.document);
+102
View File
@@ -0,0 +1,102 @@
/**
* 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 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 { diffToBlockHunks } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
/**
* F11: a host edit turn (selection/document text + instruction → rewrite).
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
* the arg is optional so existing test stubs that ignore it stay valid.
*/
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
export type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
export class EditFlow implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
/**
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
*/
private editTurn: EditTurn = async (instruction, text, opts) => {
const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text, opts);
};
/** 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) {}
/**
* F11/F12 (INV-35/39): run one host edit turn and record the result as F4
* proposal(s) — a SELECTION yields one single-range proposal over the resolved
* block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per
* changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged
* `granularity:"block"` so accept reconciles attribution per word (INV-40).
* Never mutates the document (INV-10). Returns the created proposal ids.
*/
async runEditAndPropose(
document: vscode.TextDocument,
target: EditTarget,
instruction: string,
opts?: RunEditTurnOptions,
): Promise<string[]> {
const full = document.getText();
// One turnId per gesture — the document case's N hunk-proposals all share it,
// so a single rewrite groups as one agent turn (parity with editSelection).
const turnId = `turn-${this.nextTurnSeq()}`;
const provenance = (turn: EditTurnResult) =>
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
if (target.kind === "range") {
const selected = full.slice(target.start, target.end);
const turn = await this.editTurn(instruction, selected, opts);
if (turn.replacement === "" || turn.replacement === selected) return [];
const fp = buildFingerprint(full, { start: target.start, end: target.end });
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
return id ? [id] : [];
}
const turn = await this.editTurn(instruction, full, opts);
const ids: string[] = [];
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
// granularity — one proposal per changed block (the unit a human reviews) —
// not per word. Each is tagged `granularity:"block"` so accept reconciles
// attribution per word inside the block (INV-40).
for (const h of diffToBlockHunks(full, turn.replacement)) {
const fp = buildFingerprint(full, { start: h.start, end: h.end });
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), {
turnId,
instruction,
granularity: "block",
});
if (id) ids.push(id);
}
return ids;
}
// ---- test seam (§6.4) ----
/** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */
setEditTurnForTest(fn: EditTurn): void {
this.editTurn = fn;
}
dispose(): void {
for (const d of this.disposables) d.dispose();
}
}
-147
View File
@@ -1,147 +0,0 @@
import * as vscode from "vscode";
import { randomBytes } from "crypto";
/**
* The multi-line instruction input for "Ask Claude to Edit" — BOTH the selection
* and the whole-document case. A small focused webview (a tall, resizable
* textarea + Send) opened in a split pane BELOW the document — not a comment
* thread, and not the top QuickInput (which is single-line only; VS Code has no
* multi-line input at the command-palette location). For a selection edit the
* document stays in the pane above with the selection still highlighted (VS
* Code's inactive-selection style), so the user can see exactly what Claude will
* edit; the caller has already captured the selection, so we never touch it.
*
* The split collapses when the input is submitted or cancelled, and focus is
* handed back to the document so the collapsing split doesn't reveal the bottom
* panel. `header` names the scope ("Ask Claude to Edit This Selection" /
* "…This Document").
*
* The webview only collects text and posts it to the host — no SDK or secret
* surface lives in it (INV-8/35); the sealed CSP allows no network. Because a
* webview CAN read its own textarea, Cancel / Escape confirms ONLY when there is
* text to lose (closing the tab is an explicit dismiss, no prompt). Resolves with
* the typed instruction, or `undefined` if cancelled / closed / left empty.
*/
export async function promptEditInstruction(header: string): Promise<string | undefined> {
// Remember the document editor we came from so we can hand focus back to it
// when the input closes — otherwise, when the empty split group below collapses,
// focus falls into the bottom panel (Output / Debug Console / …) and it pops
// open. Restoring editor focus leaves whatever panel state the user had untouched.
const source = vscode.window.activeTextEditor;
// Open a split editor group BELOW the current one and host the input there, so
// it sits under the document instead of covering it as a tab. The new group is
// empty, so disposing the panel on submit/cancel leaves it empty and VS Code
// collapses the split (the `workbench.editor.closeEmptyGroups` default). Falls
// back to a tab in the active group if the split command is unavailable.
try {
await vscode.commands.executeCommand("workbench.action.newGroupBelow");
} catch {
/* no split — the panel opens as a tab in the active group instead */
}
return new Promise((resolve) => {
const panel = vscode.window.createWebviewPanel(
"cowriting.askClaudeInput",
header,
{ viewColumn: vscode.ViewColumn.Active, preserveFocus: false },
{ enableScripts: true, retainContextWhenHidden: false },
);
let settled = false;
const done = (value: string | undefined): void => {
if (settled) return;
settled = true;
resolve(value);
panel.dispose();
// Hand focus back to the originating document so the collapsing split
// doesn't leave focus in (and reveal) the bottom panel.
if (source) {
void vscode.window.showTextDocument(source.document, {
viewColumn: source.viewColumn ?? vscode.ViewColumn.One,
preserveFocus: false,
});
}
};
panel.webview.onDidReceiveMessage((m: { type?: string; text?: string }) => {
const text = (m?.text ?? "").trim();
if (m?.type === "submit") {
done(text ? text : undefined);
} else if (m?.type === "cancel") {
// Confirm only if there's something to lose (we can read the textarea here).
if (!text) {
done(undefined);
return;
}
void vscode.window
.showWarningMessage("Discard your Ask-Claude instruction?", { modal: true }, "Discard")
.then((pick) => {
if (pick === "Discard") done(undefined);
// else: leave the panel open so the operator can keep editing.
});
}
});
// Closing the tab is an explicit dismiss — cancel without a prompt.
panel.onDidDispose(() => done(undefined));
panel.webview.html = htmlFor(header);
});
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
}
function htmlFor(header: string): string {
const nonce = randomBytes(16).toString("base64");
// Sealed CSP: no network; inline style only; the one script is nonce-gated.
const csp = `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';`;
const title = escapeHtml(header);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<title>${title}</title>
<style>
body { padding: 14px 16px; font-family: var(--vscode-font-family); color: var(--vscode-foreground); }
h2 { font-size: 13px; font-weight: 600; margin: 0 0 10px; }
textarea {
width: 100%; min-height: 160px; resize: vertical; box-sizing: border-box;
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; padding: 8px;
font-family: var(--vscode-editor-font-family); font-size: var(--vscode-editor-font-size); line-height: 1.4;
}
textarea::placeholder { color: var(--vscode-input-placeholderForeground); }
textarea:focus { outline: 1px solid var(--vscode-focusBorder); border-color: var(--vscode-focusBorder); }
.row { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; gap: 12px; }
.hint { color: var(--vscode-descriptionForeground); font-size: 12px; }
button {
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px;
}
button:hover { background: var(--vscode-button-hoverBackground); }
</style>
</head>
<body>
<h2>${title}</h2>
<textarea id="inst" placeholder="e.g. tighten the intro, add a conclusion, and fix the heading levels" autofocus></textarea>
<div class="row">
<span class="hint">⌘↵ / Ctrl+↵ to send · Esc to cancel</span>
<button id="send">Send to Claude</button>
</div>
<script nonce="${nonce}">
const api = acquireVsCodeApi();
const ta = document.getElementById('inst');
const focus = () => ta.focus();
focus();
window.addEventListener('focus', focus);
const submit = () => api.postMessage({ type: 'submit', text: ta.value });
const cancel = () => api.postMessage({ type: 'cancel', text: ta.value });
document.getElementById('send').addEventListener('click', submit);
ta.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); submit(); }
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
</script>
</body>
</html>`;
}
+36 -7
View File
@@ -17,6 +17,7 @@ import type { DiffViewController } from "./diffViewController";
import type { AttributionController } from "./attributionController";
import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel";
import { isAuthorable } from "./workspacePath";
import type { CoeditingRegistry } from "./coeditingRegistry";
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
private readonly disposables: vscode.Disposable[] = [];
@@ -51,13 +52,14 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
private readonly proposals: ProposalController,
private readonly diffView: DiffViewController,
private readonly attribution: AttributionController,
private readonly registry: CoeditingRegistry,
) {
this.disposables.push(
this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter,
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
// Refresh committed decorations when the baseline advances (pin / machine-landing / open).
// Refresh committed decorations when the baseline changes (establish / pin / head-refresh).
this.diffView.onDidChangeBaseline(({ uri }) => {
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
if (ed) this.renderEditor(ed);
@@ -67,6 +69,13 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
const ed = vscode.window.activeTextEditor;
if (ed && e.document === ed.document) this.scheduleRender(ed);
}),
// INV-10: re-render (and refresh CodeLens) on every gate change — exit clears
// decorations/CodeLens via renderEditor's own gate check, re-entry redraws.
this.registry.onDidChange(({ uri }) => {
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
if (ed) this.renderEditor(ed);
this.lensEmitter.fire();
}),
// the four QuickPick-backed menu commands
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
@@ -106,6 +115,9 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
private async onProposalsChanged(uri: string): Promise<void> {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (!doc || doc.languageId !== "markdown" || !isAuthorable(doc.uri.scheme)) return;
// INV-10: a doc that exited coediting between the schedule and this tick gets
// no optimistic-apply/decorations/CodeLens.
if (!this.registry.isCoediting(doc.uri)) return;
const key = this.proposals.keyFor(doc);
for (const v of this.proposals.listProposals(doc)) {
if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) {
@@ -125,6 +137,8 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []);
};
if (doc.languageId !== "markdown") return clearAll();
// INV-10: a non-entered doc gets no decorations at all.
if (!this.registry.isCoediting(doc.uri)) return clearAll();
const key = this.proposals.keyFor(doc);
const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] };
const del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]> = { human: [], claude: [], none: [] };
@@ -202,18 +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;
+269 -141
View File
@@ -10,10 +10,15 @@ import { BaselineStore } from "./baselineStore";
import { GlobalSidecarStore } from "./globalSidecarStore";
import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview";
import { GitBaselineAdapter } from "./gitBaseline";
import { EditFlow } from "./editFlow";
import { LiveProgressUi } from "./liveProgressUi";
import { EditorProposalController } from "./editorProposalController";
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
import { isAuthorable, routeEdit } from "./workspacePath";
import { CoeditingRegistry } from "./coeditingRegistry";
import { ScmSurfaceController } from "./scmSurface";
import type MarkdownIt from "markdown-it";
import { cowritingMarkdownItPlugin, type AnnotationInputs } from "./previewAnnotations";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -23,10 +28,19 @@ export interface CowritingApi {
proposalController: ProposalController;
versionGuard: VersionGuard;
diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController;
editFlow: EditFlow;
sidecarRouter: SidecarRouter;
liveProgressUi: LiveProgressUi;
editorProposalController: EditorProposalController;
coeditingRegistry: CoeditingRegistry;
scmSurfaceController: ScmSurfaceController;
/** Task 7 test seam: the REAL production `inputsFor`, so E2E exercises the
* actual registry/diffView/attribution/proposals/config wiring (not a
* hand-rolled reimplementation of it). */
previewAnnotationHost: { inputsFor(env: unknown): AnnotationInputs | undefined };
/** Task 7 (D3/D21): the markdown-it plugin factory VS Code's built-in preview
* loads via `markdown.markdownItPlugins` (package.json). */
extendMarkdownIt: (md: unknown) => unknown;
}
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -59,19 +73,66 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
}),
);
// --- F6: baseline data layer (Features #17 + #19) — workspace-INDEPENDENT ---
// The two-pane vscode.diff VIEW was removed in #34 (F10's rendered preview is
// the single review surface); only the baseline store survives, consumed by
// F7/F10. It tracks a baseline for ANY diffable doc (file or untitled), so it
// is constructed regardless of whether a folder is open. Baseline lives in VS
// Code's per-extension GLOBAL storage (always present), never the repo
// (INV-19). The machine-landing advance is wired below. The controller
// self-wires baseline capture on open.
// --- PUC-7 (native-surfaces migration): the INV-10 opt-in gate ---
// Every later native surface (SCM/QuickDiff, comments, preview annotations,
// edit commands, decorations) checks isCoediting before attaching. Persisted
// in workspaceState so a reload restores the set.
const coeditingRegistry = new CoeditingRegistry(context.workspaceState);
context.subscriptions.push(coeditingRegistry);
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.coeditDocument", () => {
const ed = vscode.window.activeTextEditor;
if (!ed || ed.document.languageId !== "markdown" || !isAuthorable(ed.document.uri.scheme)) {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to coedit it.");
return;
}
coeditingRegistry.enter(ed.document.uri);
coeditingRegistry.syncContext(ed);
}),
vscode.commands.registerCommand("cowriting.stopCoediting", () => {
const ed = vscode.window.activeTextEditor;
if (!ed) return;
coeditingRegistry.exit(ed.document.uri);
coeditingRegistry.syncContext(ed);
}),
vscode.window.onDidChangeActiveTextEditor((ed) => coeditingRegistry.syncContext(ed)),
);
coeditingRegistry.syncContext(vscode.window.activeTextEditor);
// --- Baseline router (native-surfaces migration, spec §6.4, INV-7) — workspace-
// INDEPENDENT --- The two-pane vscode.diff VIEW was removed in #34 (F10's
// rendered preview is the single review surface); only the baseline data
// layer survives, consumed by F7/F10. It tracks a baseline for ANY diffable
// doc (file or untitled), so it is constructed regardless of whether a folder
// is open. A SNAPSHOT baseline lives in VS Code's per-extension GLOBAL
// storage (always present), never the repo (INV-19); a git-tracked doc's HEAD
// baseline is never persisted (INV-7). Baselines are established only for
// documents the CoeditingRegistry has entered — see the registry wiring
// inside DiffViewController's constructor.
const gitBaseline = new GitBaselineAdapter();
context.subscriptions.push(gitBaseline);
const baselineStorageDir = context.globalStorageUri?.fsPath;
const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null;
const diffViewController = new DiffViewController(baselineStore);
const diffViewController = new DiffViewController(baselineStore, gitBaseline, coeditingRegistry);
context.subscriptions.push(diffViewController);
// Task 1 hook: expose the resolved baseline mode ("head"/"snapshot") to the
// registry's context-key sync, and re-sync `cowriting.baselineMode` whenever
// a baseline is (re)captured (entry, head-refresh, pin) — the "Mark Changes
// as Reviewed" menu entry (snapshot-only, Task 3) is gated on that key.
coeditingRegistry.baselineModeOf = (uri) => diffViewController.modeOf(uri.toString());
context.subscriptions.push(
diffViewController.onDidChangeBaseline(() => coeditingRegistry.syncContext(vscode.window.activeTextEditor)),
);
// --- Task 3: the native diff surface (spec §6.4/§5, INV-13) — QuickDiff
// gutter bars, the cowriting-baseline: content provider, "Review Changes",
// and the status-bar change count. Constructed after diffViewController
// (consumes getBaseline/modeOf/onDidChangeBaseline) and the registry
// (consumes isCoediting/onDidChange), gated on INV-10. ---
const scmSurfaceController = new ScmSurfaceController(coeditingRegistry, diffViewController);
context.subscriptions.push(scmSurfaceController);
// F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage
// home as the F6 baseline, keyed by sha256(uri) (INV-19/24). Constructed
// workspace-independently; the router falls back to it for any non-in-folder
@@ -92,39 +153,51 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
// F5 (INV-16): one shared guard — newer-major sidecars are read-only, one
// warning per doc across the three co-owning controllers.
const versionGuard = new VersionGuard(sidecarRouter);
const threadController = new ThreadController(sidecarRouter, root, versionGuard);
context.subscriptions.push(threadController);
// --- F3: live attribution (Feature #6) ---
const attributionController = new AttributionController(sidecarRouter, root, versionGuard);
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 ---
const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard);
// --- F4: propose/accept (Feature #12) ---
const proposalController = new ProposalController(
sidecarRouter,
attributionController,
root,
versionGuard,
coeditingRegistry,
);
context.subscriptions.push(proposalController);
// --- 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) and proposals (routes
// F4 accept/reject from the webview ✓/✗).
const trackChangesPreviewController = new TrackChangesPreviewController(
diffViewController,
context.extensionUri,
attributionController,
proposalController,
liveProgressUi,
);
context.subscriptions.push(trackChangesPreviewController);
// --- 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 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→
// offer→proposal loop. Consumes editFlow (offer → pending proposals) and
// liveProgressUi (the shared "asking Claude…" notification + output channel). ---
const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry, editFlow, liveProgressUi);
context.subscriptions.push(threadController);
// --- 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(proposalController, diffViewController, attributionController);
const editorProposalController = new EditorProposalController(
proposalController,
diffViewController,
attributionController,
coeditingRegistry,
);
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). Reuses the
// batched F4 seam + reports applied-vs-skipped.
// (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;
@@ -132,11 +205,17 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals.");
return;
}
await trackChangesPreviewController.acceptAll(doc);
const { applied, skipped } = await proposalController.acceptAllProposals(doc);
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): reject every pending proposal on the active doc in one gesture.
// #64 (INV-53): reject every pending proposal on the active doc in one gesture
// (native-surfaces migration: routes directly to ProposalController).
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => {
const doc = vscode.window.activeTextEditor?.document;
@@ -144,16 +223,21 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals.");
return;
}
await trackChangesPreviewController.rejectAll(doc);
const { reverted } = await proposalController.rejectAll(doc);
if (reverted > 0) {
void vscode.window.showInformationMessage(
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`,
);
}
}),
);
// --- F6 machine-landing wiring — now for ANY authorable doc ---
// The seam's single machine-landing signal advances the F6 baseline (INV-18);
// the seam can now fire on out-of-folder files too, so wire it unconditionally.
context.subscriptions.push(
attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)),
);
// NOTE (native-surfaces migration, INV-7/INV-18 retirement): the shipped
// machine-landing baseline advance (#48) — wiring
// attributionController.onDidApplyAgentEdit → diffViewController.advance —
// was removed here. A landed Claude edit now stays a visible change-since-
// baseline until the file is committed (head mode) or reviewed via
// "Mark Changes as Reviewed" (snapshot mode).
// One SHARED sidecar watcher for both controllers; self-writes are suppressed
// centrally in the repo store (only repo `.threads/` sidecars are watched —
@@ -216,114 +300,58 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
),
);
// F3 SLICE-5 → F4 SLICE-4: the live turn — selection + instruction → one
// claude-code SDK turn (liveTurn.ts, INV-8) → a PENDING PROPOSAL (F4,
// INV-10); the applyAgentEdit seam (INV-9) now fires only on accept.
// Task 6 (D19): the comments-first ask replaces the old inline turn
// plumbing here — the ask now IS a comment. threadController.askClaude()
// opens a focused comment box on the current selection (or, with no
// selection, a top-anchored whole-document thread); the comment→reply→
// offer→proposal loop (ThreadController.respondInThread/makeThreadEdit)
// takes it from there. Kept as its own command (rather than folded into
// cowriting.edit) for the host E2E harness and the internal seams.
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.editSelection", async () => {
const editor = vscode.window.activeTextEditor;
// F8: authoring works on any file: or untitled: doc (the router decides
// where its artifact is stored). Each failure still names its real reason
// (no editor / no selection / a non-{file,untitled} read-only view) — not
// always "select some text" (#24's per-condition messaging).
const reason = selectionRejection({
hasEditor: !!editor,
selectionEmpty: editor?.selection.isEmpty ?? true,
scheme: editor?.document.uri.scheme ?? "",
});
if (reason) {
void vscode.window.showWarningMessage(reason);
await threadController.askClaude();
}),
);
// Code-review fix (Finding 1, session native-surfaces-exec): `cowriting.editDocument`
// 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
// whole-document ask when the (now-focused) editor's selection is empty (D19). Kept
// registered + hidden from the palette for the host E2E harness and #42's
// tab-targeting behavior; `EditFlow.runEditAndPropose` (untouched) stays the E2E seam
// for driving a turn without the native comment UI — see f12Reach.test.ts /
// f11Toolbar.test.ts / f12Accept.test.ts / f12Review.test.ts.
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.editDocument", 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 ask Claude to edit it.");
return;
}
if (!editor) return; // unreachable once reason is null, but narrows the type
const document = editor.document;
const selection = editor.selection; // non-empty (selectionRejection guaranteed it)
// Capture the selection text + anchor BEFORE prompting — the inline prompt
// moves the cursor to the document top (where its box anchors), which would
// otherwise collapse the selection we act on (spec §6.5 PUC-1: anchor
// captured pre-turn so mid-turn edits can't skew it).
const selectedText = document.getText(selection);
const fp = buildFingerprint(document.getText(), {
start: document.offsetAt(selection.start),
end: document.offsetAt(selection.end),
});
// The instruction prompt is the multi-line split-below webview box (shared
// with the document case, via the preview controller); the document above
// keeps the selection highlighted while it's open. selectedText/fp were
// captured above, so moving focus to the box doesn't affect what we edit.
const instruction = await trackChangesPreviewController.askEditInstruction(
"Ask Claude to Edit This Selection",
);
if (!instruction) return;
const turnId = `turn-${Date.now().toString(36)}`;
try {
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const { runEditTurn } = await import("./liveTurn");
const ui = liveProgressUi.begin(instruction, progress, token);
let turn;
try {
turn = await runEditTurn(instruction, selectedText, {
onProgress: ui.onProgress,
signal: ui.signal,
});
} catch (err) {
// #60 (INV-47): a user cancel surfaces as "cancelled", not a failure.
if (token.isCancellationRequested) {
void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled.");
return;
}
throw err;
}
if (turn.replacement === "") {
void vscode.window.showWarningMessage(
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
);
return;
}
if (turn.replacement === selectedText) {
void vscode.window.showInformationMessage(
"Cowriting: Claude proposed no change to the selection.",
);
return;
}
// F4 (INV-10): the turn ends in a PROPOSAL, not a buffer mutation.
// The seam now fires only on accept (ProposalController, INV-9).
const id = await proposalController.propose(
document,
fp,
turn.replacement,
{
kind: "agent",
id: "claude",
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId },
},
{ turnId, instruction },
);
if (id) {
void vscode.window.showInformationMessage(
"Cowriting: Claude proposed an edit — review the diff at the highlighted range (✓ accept / ✗ reject).",
);
}
},
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) {
await vscode.window.showTextDocument(doc);
}
await threadController.askClaude();
}),
);
// The single user-facing "Ask Claude to Edit" gesture (one command, one
// keybinding, one menu entry). It routes to the selection flow (editSelection)
// or the whole-document flow (editDocument) by selection/context — see
// routeEdit. The two underlying commands stay registered for the host E2E
// harness and the internal seams, but are hidden from the palette.
// routeEdit. Both underlying commands now end at ThreadController.askClaude()
// (comments-first ask, D19); they stay registered for the host E2E harness and
// the internal seams, but are hidden from the palette.
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => {
const editor = vscode.window.activeTextEditor;
@@ -341,9 +369,47 @@ 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
// at the call site and avoids the no-op calls for a non-entered doc.
const renderIfOpen = (doc: vscode.TextDocument) => {
if (isAuthorable(doc.uri.scheme)) {
if (isAuthorable(doc.uri.scheme) && coeditingRegistry.isCoediting(doc.uri)) {
// Finding 3 (final whole-branch review): DiffViewController only
// establishes a baseline on the registry's enter TRANSITION and, at
// startup, for documents already open. A doc that is a PERSISTED
// coediting member but wasn't open yet at either of those moments (e.g.
// lazily restored after a window reload) reaches this handler with no
// baseline/mode — establish it now so QuickDiff/Review-Changes/Mark-
// Reviewed never see a silent empty-baseline gap.
if (diffViewController.modeOf(doc.uri.toString()) === undefined) void diffViewController.establish(doc);
threadController.renderAll(doc);
attributionController.loadAll(doc);
proposalController.renderAll(doc);
@@ -352,16 +418,78 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
vscode.workspace.textDocuments.forEach(renderIfOpen);
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
// --- Task 7 (D3/D21, PUC-3): annotations in the BUILT-IN Markdown preview ---
// `env.currentDocument` (VS Code's markdown-it render env) is an OBSERVED
// preview-engine behavior, not a documented contract, so `lastCoeditedUri`
// is the fallback that keeps single-doc correctness when it's absent — the
// last editor that WAS coediting when it lost focus (e.g. focus moved to the
// preview pane itself) stays the annotation target until a different
// coedited doc becomes active.
let lastCoeditedUri: string | undefined;
const trackLastCoedited = (ed: vscode.TextEditor | undefined) => {
if (ed && coeditingRegistry.isCoediting(ed.document.uri)) lastCoeditedUri = ed.document.uri.toString();
};
trackLastCoedited(vscode.window.activeTextEditor);
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(trackLastCoedited),
// The active editor doesn't itself change when `cowriting.coeditDocument`
// enters IT into coediting — re-check on every gate change too, so the
// very first render after entering already has an annotation target.
coeditingRegistry.onDidChange(() => trackLastCoedited(vscode.window.activeTextEditor)),
);
const previewAnnotationHost = {
inputsFor(env: unknown): AnnotationInputs | undefined {
const envUri = (env as { currentDocument?: { toString(): string } } | undefined)?.currentDocument?.toString();
// Finding 1 (final whole-branch review): the `lastCoeditedUri` fallback
// is for an ABSENT env only (env.currentDocument didn't resolve) — a
// present envUri is authoritative for the doc actually being previewed
// and must never fall back to a different (or since-exited) document's
// inputs. Either way, the winning key must currently be a coediting
// member (INV-10) — an exited doc's stale `lastCoeditedUri` fails this
// check too, closing the "preview stays annotated after stopCoediting"
// gap.
const key = envUri === undefined ? lastCoeditedUri : envUri;
if (!key || !coeditingRegistry.list().includes(key)) return undefined;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
if (!doc) return undefined;
const baseline = diffViewController.getBaseline(key);
return {
baselineText: baseline?.text,
baselineReason: baseline?.reason,
spans: attributionController.spansFor(doc),
proposals: proposalController.listProposals(doc),
enabled: vscode.workspace.getConfiguration("cowriting").get<boolean>("annotations", true),
};
},
};
// Toggle: flips `cowriting.annotations` + refreshes the built-in preview
// (VS Code re-invokes extendMarkdownIt's plugin on the next render — no
// extension-side re-render call exists beyond the standard refresh command).
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.toggleAnnotations", async () => {
const config = vscode.workspace.getConfiguration("cowriting");
const current = config.get<boolean>("annotations", true);
await config.update("annotations", !current, vscode.ConfigurationTarget.Global);
await vscode.commands.executeCommand("markdown.preview.refresh");
}),
);
return {
threadController,
attributionController,
proposalController,
versionGuard,
diffViewController,
trackChangesPreviewController,
editFlow,
sidecarRouter,
liveProgressUi,
editorProposalController,
coeditingRegistry,
scmSurfaceController,
previewAnnotationHost,
extendMarkdownIt: (md: unknown) => cowritingMarkdownItPlugin(md as MarkdownIt, previewAnnotationHost),
};
}
+131
View File
@@ -0,0 +1,131 @@
/**
* GitBaselineAdapter — resolves a document's git-HEAD baseline via the built-in
* Git extension API (spec §6.4, INV-7). Spike-verified: Repository.show('HEAD',
* path) + state.onDidChange work as assumed; the change event is coarse, so
* re-reads key off state.HEAD.commit; a nested repo may need openRepository().
*
* Hardening (Task 2 E2E, host-verified): for a repo that is NOT a workspace
* folder (the common case here — F8/#19 precedent, coediting works on any
* file), the git extension's own filesystem watcher does not reliably notice
* an out-of-band commit (e.g. a `git commit` run from a terminal, or in the
* host E2E) — `repo.state.onDidChange` can simply never fire. We additionally
* watch `.git/logs/HEAD` (the reflog VS Code itself also watches for
* in-workspace repos) with Node's own `fs.watch` and nudge `repo.status()`,
* which reliably re-syncs `state.HEAD` and lets the existing onDidChange path
* do the rest. Best-effort: any failure here just falls back to the passive
* listener.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import * as vscode from "vscode";
interface GitRepository {
rootUri: vscode.Uri;
state: { HEAD?: { commit?: string }; onDidChange: vscode.Event<void> };
show(ref: string, path: string): Promise<string>;
status(): Promise<void>;
}
interface GitAPI {
repositories: GitRepository[];
getRepository(uri: vscode.Uri): GitRepository | null;
openRepository(root: vscode.Uri): Promise<GitRepository | null>;
onDidOpenRepository: vscode.Event<GitRepository>;
}
export interface HeadBaseline { text: string; commit: string }
export class GitBaselineAdapter implements vscode.Disposable {
private api: GitAPI | null | undefined; // undefined = not resolved yet; null = unavailable
private readonly hooked = new WeakSet<GitRepository>();
private readonly lastCommit = new Map<string, string>(); // repo root → HEAD commit
private readonly emitter = new vscode.EventEmitter<void>();
readonly onDidChangeHead = this.emitter.event;
private readonly disposables: vscode.Disposable[] = [this.emitter];
private async gitApi(): Promise<GitAPI | null> {
if (this.api !== undefined) return this.api;
try {
const ext = vscode.extensions.getExtension("vscode.git");
if (!ext) return (this.api = null);
const exports = ext.isActive ? ext.exports : await ext.activate();
this.api = exports.getAPI(1) as GitAPI;
this.disposables.push(this.api.onDidOpenRepository((r) => this.hook(r)));
for (const r of this.api.repositories) this.hook(r);
} catch {
this.api = null;
}
return this.api;
}
/** HEAD blob text + commit for a tracked file; null when untracked / no repo / no HEAD. */
async headFor(uri: vscode.Uri): Promise<HeadBaseline | null> {
if (uri.scheme !== "file") return null;
const git = await this.gitApi();
if (!git) return null;
let repo = git.getRepository(uri);
if (!repo) repo = await this.discoverNested(git, uri);
if (!repo) return null;
this.hook(repo);
try {
const text = await repo.show("HEAD", uri.fsPath);
return { text, commit: repo.state.HEAD?.commit ?? "" };
} catch {
return null; // untracked, ignored, or no commit yet → snapshot mode
}
}
/** Spike finding: a nested repo may not be auto-opened — walk up for .git, then openRepository. */
private async discoverNested(git: GitAPI, uri: vscode.Uri): Promise<GitRepository | null> {
let dir = path.dirname(uri.fsPath);
for (let i = 0; i < 32; i++) {
if (fs.existsSync(path.join(dir, ".git"))) {
await git.openRepository(vscode.Uri.file(dir));
return git.getRepository(uri);
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
/** Watch a repo; fire onDidChangeHead only when HEAD's commit actually moves. */
private hook(repo: GitRepository): void {
if (this.hooked.has(repo)) return;
this.hooked.add(repo);
this.lastCommit.set(repo.rootUri.toString(), repo.state.HEAD?.commit ?? "");
this.disposables.push(
repo.state.onDidChange(() => {
const key = repo.rootUri.toString();
const commit = repo.state.HEAD?.commit ?? "";
if (this.lastCommit.get(key) !== commit) {
this.lastCommit.set(key, commit);
this.emitter.fire();
}
}),
);
this.watchReflog(repo);
}
/** Best-effort nudge: watch `.git/logs/HEAD` directly and force `repo.status()`
* on any write, so an out-of-band commit is picked up even when the git
* extension's own watcher doesn't fire (see the class doc). */
private watchReflog(repo: GitRepository): void {
try {
const reflog = path.join(repo.rootUri.fsPath, ".git", "logs", "HEAD");
if (!fs.existsSync(reflog)) return;
let pending: ReturnType<typeof setTimeout> | undefined;
const watcher = fs.watch(reflog, () => {
if (pending) clearTimeout(pending);
pending = setTimeout(() => void repo.status().catch(() => {}), 150);
});
this.disposables.push({ dispose: () => watcher.close() });
} catch {
// best-effort only — the passive repo.state.onDidChange listener still applies.
}
}
dispose(): void {
for (const d of this.disposables) d.dispose();
}
}
+300
View File
@@ -0,0 +1,300 @@
/**
* previewAnnotations — Task 7 (D3/D21, PUC-3): a pure, vscode-free markdown-it
* plugin that annotates the BUILT-IN VS Code Markdown preview with the F10
* authorship/change-mark vocabulary (`.cw-ins-claude` / `.cw-ins-human` /
* `.cw-del`) — the native-surfaces migration's replacement for the sunset
* webview's review body (spec §6.4/§6.10). Reuses the shipped sentinel
* discipline (`injectSentinels`/`sentinelsToSpans`, #33/#47-hardened) instead
* of inventing a new one: `annotateSource` computes PUA-sentinel-wrapped
* markdown SOURCE (never HTML) against the F6/F7 baseline + F3 spans + F4
* pending proposals; `cowritingMarkdownItPlugin` wires that transform into the
* shared `markdown-it` instance (the SAME instance VS Code's built-in preview
* uses via `markdown.markdownItPlugins`) as (a) a core rule — swap `state.src`
* before `normalize` — and (b) a full-render wrapper that turns surviving
* sentinel pairs into `<span class="cw-…">`.
*
* Implementation note (resolved drift from the migration plan's Task 7 sketch,
* which described (b) as "a text renderer rule"): `sentinelsToSpans` is a
* stateful, single-pass walk over one HTML string — installing it as a
* PER-TOKEN `renderer.rules.text` hook would reset that state at every text
* token boundary and silently break the "survives emphasis/tag boundaries"
* invariant (#33 CASE3) for any span whose sentinel-open and sentinel-close
* land in DIFFERENT text tokens (e.g. either side of a `**bold**` run — the
* exact scenario CASE3 exists to cover). Wrapping `md.renderer.render` instead
* runs the walk ONCE over the fully-assembled HTML, exactly like the F9/F10
* `colorByAuthor` precedent (`render(injected)` — a whole-block HTML string,
* not a token fragment) — this is what actually delivers the stated invariant,
* robust to callers that invoke `md.render()` directly or `md.parse()` +
* `md.renderer.render()` separately (VS Code's own scroll-sync `data-line`
* injection uses the latter).
*
* `host.inputsFor(env)` is the ONE thin, vscode-touching seam (owned by
* `extension.ts`): `env.currentDocument` is an OBSERVED VS Code preview-engine
* behavior, not a documented contract, so the host falls back to the last
* actively-coedited document when it is absent (see extension.ts's
* `lastCoeditedUri`).
*
* Deliberate choice: the mermaid-fence rendering (the `options.highlight`
* override installed below) applies to EVERY markdown preview, coedited or
* not — it is not gated on coediting state. Gating it would make a diagram's
* rendering flip (mermaid <-> plain fence) the instant a doc enters or exits
* coediting, which is a worse surprise than always rendering mermaid diagrams
* as diagrams; only the change-tracking OVERLAY on top (`buildMermaidQueue`'s
* diff augmentation) is gated on having a baseline to diff against.
*/
import type MarkdownIt from "markdown-it";
import { diffMermaid } from "./mermaidDiff";
import {
diffBlocks,
injectSentinels,
landedTextOf,
MERMAID_LEGEND,
mermaidFenceBody,
sentinelsToSpans,
splitBlocksWithRanges,
wordEditHunks,
type AuthorSpan,
type ProposalView,
type TaggedSpan,
} from "./trackChangesModel";
export interface AnnotationInputs {
baselineText: string | undefined;
baselineReason: "entered" | "pinned" | "head" | undefined;
spans: AuthorSpan[];
proposals: ProposalView[];
enabled: boolean;
}
/**
* Inject the sentinel vocabulary into `text` block-by-block (INV-23: code/
* mermaid fences stay atomic — never sentinel-injected, never word-refined) so
* the delimiter-run + block-marker safety `injectSentinels` carries applies
* PER BLOCK, not just at the very top of the whole document (its clamps are
* anchored to a block's own start, matching every other caller — `colorByAuthor`
* is always invoked per block too).
*/
function injectAllSentinels(text: string, marks: TaggedSpan[]): string {
if (marks.length === 0) return text;
const blocks = splitBlocksWithRanges(text);
if (blocks.length === 0) return text;
let out = "";
let cursor = 0;
for (const b of blocks) {
out += text.slice(cursor, b.start); // preserve inter-block gaps (blank lines) verbatim
if (b.type === "prose") {
const blockMarks = marks.filter((m) => m.end > b.start && m.start < b.end);
out += blockMarks.length ? injectSentinels(b.raw, b.start, blockMarks) : b.raw;
} else {
out += b.raw; // INV-23: fences are never annotated
}
cursor = b.end;
}
out += text.slice(cursor); // trailing content after the last block
return out;
}
/**
* Pure: markdown source -> sentinel-annotated source (authorship + change +
* proposal marks vs the F6/F7 baseline). Skips everything when annotations are
* disabled, or (the #48 rule) once the baseline was just PINNED with zero
* LANDED diff — pending F4 proposals are unaffected by that gate (matching the
* shipped preview's "pin→clean, proposals still show", #48/INV-33).
*/
export function annotateSource(src: string, inputs: AnnotationInputs): string {
if (!inputs.enabled) return src;
const proposals = inputs.proposals ?? [];
const baselineText = inputs.baselineText ?? src; // no baseline → no change-marks
const landedText = landedTextOf(src, proposals);
const pinnedZeroDiff = inputs.baselineReason === "pinned" && landedText === baselineText;
const insSpans: TaggedSpan[] = [];
const deletions: { at: number; text: string }[] = [];
const appliedRanges: { start: number; end: number }[] = [];
// (c) pending F4 proposals already sit applied in `src` (F12 optimistic
// apply, INV-48) — mark the applied range ins-<author> (`p.author` — a
// proposal defaults to "claude" per `ProposalView.author`, but a human can
// also be the proposer, e.g. a human-authored suggestion routed through the
// same F4 pending-proposal path) and resurface the pre-apply original as a
// deletion at the same point.
for (const p of proposals) {
if (p.anchorStart === null || p.anchorEnd === null) continue;
appliedRanges.push({ start: p.anchorStart, end: p.anchorEnd });
if (p.anchorEnd > p.anchorStart) insSpans.push({ start: p.anchorStart, end: p.anchorEnd, tag: p.author ?? "claude" });
const original = p.original ?? p.replaced;
if (original) deletions.push({ at: p.anchorStart, text: original });
}
// (a)/(b) committed changes-since-baseline. Each hunk (wordEditHunks(src,
// baseline) — start/end in SRC coords, `replacement` = the baseline-side text
// for that span) yields (a) insertion marks from the F3 `spans` CLIPPED to the
// hunk's changed range — a hunk may straddle more than one author (e.g. two
// adjacent words typed by different authors with nothing committed between
// them), so spans are clipped individually, never the whole hunk tagged with
// one author — and (b) the baseline's dropped text reinserted at the hunk
// start. A hunk owned by an applied pending proposal is already marked above.
if (!pinnedZeroDiff && landedText !== baselineText) {
for (const h of wordEditHunks(src, baselineText)) {
if (appliedRanges.some((r) => h.start < r.end && h.end > r.start)) continue;
for (const s of inputs.spans) {
const start = Math.max(s.start, h.start);
const end = Math.min(s.end, h.end);
if (end > start) insSpans.push({ start, end, tag: s.author });
}
if (h.replacement) deletions.push({ at: h.start, text: h.replacement });
}
}
if (insSpans.length === 0 && deletions.length === 0) return src;
// Splice deletion reinsertions into the source high→low (so earlier offsets
// stay valid), shifting every mark recorded so far and appending a "del" mark
// over the freshly-inserted text.
const marks: TaggedSpan[] = insSpans.map((s) => ({ ...s }));
let text = src;
for (const d of [...deletions].sort((a, b) => b.at - a.at)) {
text = text.slice(0, d.at) + d.text + text.slice(d.at);
for (const m of marks) {
if (m.start >= d.at) m.start += d.text.length;
if (m.end >= d.at) m.end += d.text.length;
}
marks.push({ start: d.at, end: d.at + d.text.length, tag: "del" });
}
return injectAllSentinels(text, marks);
}
/** One `buildMermaidQueue` entry: a changed mermaid fence's own body (for
* matching, `mermaidFenceBody` normalization — no trailing newline) paired
* with its `diffMermaid`-augmented replacement source. `consumed` is reset by
* the `render` wrapper (see `cowritingMarkdownItPlugin`) so the SAME entries
* can back a re-render without a fresh parse. */
interface MermaidQueueEntry {
readonly curBody: string;
readonly source: string;
consumed: boolean;
}
/**
* Per-render queue of mermaid-fence overrides: one entry per `changed` atomic
* mermaid op (Task 7 §2.6 parity: reuses `diffBlocks`' baseline/current block
* pairing, the SAME pairing `renderReview`/`renderOp` used in the sunset
* webview, so a changed mermaid fence is re-emitted through `diffMermaid`
* exactly as it was there — INV-23 fences stay atomic, INV-29..31 styling
* directives) that has a diffable before/current pair AND a successful
* `diffMermaid` augmentation. `[]` when there is no baseline to diff against,
* annotations are disabled, or the landed text equals the baseline (covers
* the #48 pinned-zero-diff case too, since `diffBlocks` on identical text
* yields no `changed` ops).
*
* Diffed against `landedText` (proposals reverted), matching the "committed
* change since baseline" boundary `annotateSource`'s own `wordEditHunks(src,
* baselineText)` gate already draws (a pending, not-yet-accepted proposal
* isn't a landed change) — a fence a pending proposal's anchor overlaps is
* left unaugmented, same as the rest of that gate.
*
* NOT positional: `src` (what markdown-it actually parses fences from) and
* `landedText` (what this function diffs) can disagree on mermaid-fence COUNT
* or ORDER — e.g. a pending proposal optimistically applied in `src` (F12,
* INV-48) inserts a whole new mermaid fence that `landedText` (proposals
* reverted) doesn't have, or `annotateSource`'s committed-deletion reinsert
* splices an extra fence into the rendered source. `options.highlight`
* (`cowritingMarkdownItPlugin`) matches each queue entry to a fence by BODY,
* not by position — a fence with no matching entry renders verbatim (the
* queue is a bag of `changed` fences to find, not a strict per-fence
* override list), so misalignment degrades to verbatim exactly as intended,
* never substitutes the wrong diagram's augmentation into the wrong fence.
*
* Pure, vscode-free (INV-6).
*/
function buildMermaidQueue(src: string, inputs: AnnotationInputs): MermaidQueueEntry[] {
if (!inputs.enabled || inputs.baselineText === undefined) return [];
const baselineText = inputs.baselineText;
const landedText = landedTextOf(src, inputs.proposals ?? []);
if (landedText === baselineText) return [];
const entries: MermaidQueueEntry[] = [];
for (const op of diffBlocks(baselineText, landedText)) {
if (op.kind !== "changed" || op.block.type !== "mermaid") continue; // unchanged/added/removed: no override needed
const curBody = mermaidFenceBody(op.block.raw);
const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null;
if (curBody === null || beforeBody === null) continue;
const result = diffMermaid(beforeBody, curBody);
if (result.kind === "augmented") entries.push({ curBody, source: result.source, consumed: false }); // INV-30 fallback: verbatim
}
return entries;
}
/**
* markdown-it plugin factory: the host supplies `AnnotationInputs` per render
* (`host.inputsFor(env)`; `undefined` = no coediting context for this render —
* source passed through untouched). Installs (a) a core rule BEFORE `normalize`
* that swaps `state.src` for `annotateSource(...)`, and (b) a full-render
* wrapper (see module docstring) that turns surviving sentinel pairs into
* `cw-…` spans. Also teaches ```mermaid fences to render as `<pre class="mermaid">`
* (Q4/Task 7 Step 2.6) via `options.highlight` — the same extension point the
* built-in preview's own fence rule already calls for syntax highlighting — so
* the bundled `media/preview-mermaid.js` previewScript has something to run
* mermaid over (the built-in preview does not render mermaid on its own). A
* CHANGED mermaid fence's source is re-emitted through `diffMermaid` first
* (`buildMermaidQueue`, Task 7 §2.6 parity) so mermaid renders the intra-
* diagram diff (INV-29..31), not just the current diagram. `options.highlight`
* has no `env`/`state` parameter of its own, so the per-render queue is
* threaded through a closure variable set by the core rule (which DOES see
* `state.env`) and consumed BY BODY MATCH, not fence position (see
* `buildMermaidQueue`), by `highlight`: each fence's `code` is matched against
* unconsumed queue entries in order, the first match is consumed (so a later
* fence with the same body still finds its own entry), and a fence with no
* match renders verbatim WITHOUT consuming anything (so a later fence can
* still match). markdown-it's `parse` (core rules) always completes before
* `renderer.render` invokes `highlight`, and a single md instance never
* renders concurrently, so the closure handoff itself is safe — but VS Code
* may call `md.parse()` and `md.renderer.render()` separately and re-render
* cached tokens without a fresh parse (the same caller pattern the module
* docstring notes for scroll-sync), which would otherwise replay a queue whose
* entries were already marked consumed by the prior render; the `render`
* wrapper resets every entry's `consumed` flag so each render pass starts with
* the full queue available again.
*/
export function cowritingMarkdownItPlugin(
md: MarkdownIt,
host: { inputsFor(env: unknown): AnnotationInputs | undefined },
): MarkdownIt {
let mermaidQueue: MermaidQueueEntry[] = [];
md.core.ruler.before("normalize", "cowriting-annotate", (state) => {
const inputs = host.inputsFor(state.env);
mermaidQueue = inputs ? buildMermaidQueue(state.src, inputs) : [];
if (!inputs) return;
state.src = annotateSource(state.src, inputs);
});
const baseRender = md.renderer.render.bind(md.renderer);
md.renderer.render = (tokens, options, env) => {
for (const entry of mermaidQueue) entry.consumed = false; // re-render-without-reparse safety net
return sentinelsToSpans(baseRender(tokens, options, env), "ins");
};
const baseHighlight = md.options.highlight;
md.options.highlight = (code, lang, attrs) => {
if (lang?.trim().toLowerCase() === "mermaid") {
const normalized = code.replace(/\n+$/, ""); // token.content keeps a trailing LF; mermaidFenceBody doesn't
const entry = mermaidQueue.find((e) => !e.consumed && e.curBody === normalized);
if (entry) entry.consumed = true;
const legend = entry ? MERMAID_LEGEND : "";
return `<pre class="mermaid" style="all: unset;">${escapeMermaidSource(entry?.source ?? code)}</pre>${legend}`;
}
return baseHighlight?.(code, lang, attrs) ?? md.utils.escapeHtml(code);
};
return md;
}
/** Minimal HTML-escape for a mermaid fence body (no markdown parsing — verbatim diagram source). */
function escapeMermaidSource(source: string): string {
return source
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n+$/, "");
}
+41 -3
View File
@@ -19,6 +19,7 @@ import type { AttributionController } from "./attributionController";
import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath";
import { wordEditHunks, type ProposalView } from "./trackChangesModel";
import type { CoeditingRegistry } from "./coeditingRegistry";
/** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedProposal {
@@ -56,11 +57,36 @@ export class ProposalController implements vscode.Disposable {
private readonly attribution: AttributionController,
private readonly rootDir: string | undefined,
private readonly guard: VersionGuard,
private readonly registry: CoeditingRegistry,
) {
this.disposables.push(this.statusItem);
this.disposables.push(this.onDidChangeProposalsEmitter);
this.disposables.push(
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
// PUC-7 restore-on-enter (mirrors ThreadController/AttributionController):
// `renderAll` is the only path that recomputes state.live/unresolved AND
// fires onDidChangeProposals — the event EditorProposalController listens
// to for optimistic-apply (decorations/CodeLens). Without this, a FIRST
// entry into coediting (registry.enter()) never fires it, so pre-existing
// pending proposals stay un-applied/undecorated until the next edit.
// `renderAll` already gates on isCoediting; exit intentionally does
// nothing here (it clears only the live/unresolved recompute, not the
// persisted artifact — EditorProposalController's own gate hides the
// decorations). Registered after AttributionController's own subscription
// (construction order in extension.ts) so decorateCommitted's spansFor
// read sees fresh attribution on the same enter transition.
this.registry.onDidChange(({ uri, coediting }) => {
if (!coediting) {
// Finding 5 (final whole-branch review): exit hides the status-bar
// item too — otherwise a stale "N stale proposals" warning for a
// doc that's no longer even coediting stays pinned in the bar until
// some unrelated render happens to fire for it.
if (vscode.window.activeTextEditor?.document.uri.toString() === uri) this.statusItem.hide();
return;
}
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) this.renderAll(doc);
}),
);
}
@@ -380,8 +406,13 @@ export class ProposalController implements vscode.Disposable {
/**
* F12/#64 (INV-51): ACCEPT an optimistically-applied proposal — the text is
* already in the buffer, so this only advances the F6 baseline (machine-landing,
* via `attribution.signalLanded`) and clears the proposal. No re-application.
* already in the buffer, so this only marks the attribution landing (via
* `attribution.signalLanded`) and clears the proposal. No re-application.
* Native-surfaces migration (INV-7/INV-18 retirement, spec §6.4): this no
* longer advances the baseline — a landed proposal stays a visible change-
* since-baseline until commit (head mode) or "Mark Changes as Reviewed"
* (snapshot mode); nothing here subscribes to `signalLanded` for baseline
* purposes anymore (the `onDidApplyAgentEdit → advance` wire was removed).
*/
async finalizeInPlace(docPath: string, proposalId: string): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
@@ -454,7 +485,13 @@ export class ProposalController implements vscode.Disposable {
// ---- PUC-4: load / external change / resolve-or-flag --------------------------------
/** Load + (re)render every pending proposal at its resolved anchor (or flagged). */
/** Load + (re)render every pending proposal at its resolved anchor (or flagged).
* INV-10: the RENDER portion (live/unresolved recompute, the status bar, and
* the onDidChangeProposals fire that drives F12's optimistic-apply/CodeLens/
* decorations) is gated on coediting — `propose()` itself stays ungated (it
* always persists), and so does the state LOAD below: `acceptById`/`rejectById`
* key off `this.docs` (populated here) regardless of coediting, since deciding
* an already-persisted proposal is not a "rendering" surface. */
renderAll(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return;
const docPath = this.keyOf(document);
@@ -462,6 +499,7 @@ export class ProposalController implements vscode.Disposable {
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
state.live.clear();
state.unresolved.clear();
if (!this.registry.isCoediting(document.uri)) return;
const text = document.getText();
for (const proposal of state.artifact.proposals) {
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
+120
View File
@@ -0,0 +1,120 @@
/**
* ScmSurfaceController — the native diff surface (spec §6.4/§5, INV-13).
* Owns: the "Cowriting" SourceControl + QuickDiffProvider (gutter bars), the
* cowriting-baseline: TextDocumentContentProvider, the "Review Changes" native
* diff command, and the "✦ Coediting · N changes" status-bar item. Gated on
* the CoeditingRegistry (INV-10): non-entered docs resolve no original.
*/
import * as path from "node:path";
import * as vscode from "vscode";
import type { CoeditingRegistry } from "./coeditingRegistry";
import type { DiffViewController } from "./diffViewController";
import { countLineHunks } from "./trackChangesModel";
export const BASELINE_SCHEME = "cowriting-baseline";
export function baselineUriFor(docUri: vscode.Uri): vscode.Uri {
const name = path.basename(docUri.path) || "untitled.md";
return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: `/${name}`, query: encodeURIComponent(docUri.toString()) });
}
export class ScmSurfaceController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly statusItem: vscode.StatusBarItem;
private readonly counts = new Map<string, number>();
private readonly baselineEmitter = new vscode.EventEmitter<vscode.Uri>();
private readonly recountTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private readonly registry: CoeditingRegistry,
private readonly diffView: DiffViewController,
) {
this.statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
this.statusItem.command = "cowriting.reviewChanges";
this.statusItem.tooltip = "Cowriting — click to review changes (baseline ⟷ live)";
this.disposables.push(this.statusItem, this.baselineEmitter);
this.disposables.push(
vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, {
onDidChange: this.baselineEmitter.event,
provideTextDocumentContent: (uri) =>
this.diffView.getBaseline(decodeURIComponent(uri.query))?.text ?? "",
}),
);
const sc = vscode.scm.createSourceControl("cowriting", "✦ Cowriting");
sc.quickDiffProvider = {
provideOriginalResource: (uri: vscode.Uri) =>
this.registry.isCoediting(uri) && this.diffView.getBaseline(uri.toString())
? baselineUriFor(uri)
: null,
};
this.disposables.push(sc);
this.disposables.push(
vscode.commands.registerCommand("cowriting.reviewChanges", () => this.openReview()),
this.diffView.onDidChangeBaseline(({ uri }) => {
this.baselineEmitter.fire(baselineUriFor(vscode.Uri.parse(uri)));
this.scheduleRecount(uri);
}),
this.registry.onDidChange(({ uri, coediting }) => {
if (coediting) this.scheduleRecount(uri);
this.refreshStatus();
}),
vscode.workspace.onDidChangeTextDocument((e) => {
if (this.registry.isCoediting(e.document.uri)) this.scheduleRecount(e.document.uri.toString());
}),
vscode.window.onDidChangeActiveTextEditor(() => this.refreshStatus()),
);
this.refreshStatus();
}
changeCount(uriString: string): number {
return this.counts.get(uriString) ?? 0;
}
private async openReview(): Promise<void> {
const ed = vscode.window.activeTextEditor;
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
void vscode.window.showWarningMessage("Cowriting: not coediting this document.");
return;
}
this.baselineEmitter.fire(baselineUriFor(ed.document.uri));
await vscode.commands.executeCommand(
"vscode.diff",
baselineUriFor(ed.document.uri),
ed.document.uri,
`${path.basename(ed.document.uri.path)} — Coediting (baseline ⟷ live)`,
{ preview: true },
);
}
private scheduleRecount(uriString: string): void {
const prev = this.recountTimers.get(uriString);
if (prev !== undefined) clearTimeout(prev);
this.recountTimers.set(uriString, setTimeout(() => {
this.recountTimers.delete(uriString);
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
const baseline = this.diffView.getBaseline(uriString);
if (!doc || !baseline) return;
this.counts.set(uriString, countLineHunks(baseline.text, doc.getText()));
this.refreshStatus();
}, 150));
}
private refreshStatus(): void {
const ed = vscode.window.activeTextEditor;
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
this.statusItem.hide();
return;
}
const n = this.counts.get(ed.document.uri.toString()) ?? 0;
this.statusItem.text = `$(sparkle) Coediting · ${n} change${n === 1 ? "" : "s"}`;
this.statusItem.show();
}
dispose(): void {
for (const t of this.recountTimers.values()) clearTimeout(t);
for (const d of this.disposables) d.dispose();
}
}
+293 -16
View File
@@ -15,6 +15,10 @@ import { gitUserEmail } from "./identity";
import { addThread, appendMessage, setStatus } from "./threadModel";
import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath";
import type { CoeditingRegistry } from "./coeditingRegistry";
import type { EditFlow, EditTarget } from "./editFlow";
import type { LiveProgressUi } from "./liveProgressUi";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
/** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedThread {
@@ -22,8 +26,22 @@ export interface RenderedThread {
status: "open" | "resolved";
orphaned: boolean;
range: { startLine: number; endLine: number };
/** Finding 2 (final whole-branch review): the raw `contextValue` VS Code's
* `comments/commentThread/*` `when`-clauses key on — exposed directly so
* E2E can assert the status token survives an offer transition without a
* DOM/UI query. */
contextValue: string;
}
/** D10/PUC-8: every human comment on a coedited doc runs this reply turn. */
const REPLY_PROMPT = (ask: string): string =>
"Reply conversationally (2-4 sentences) to this remark about the text, then, if the remark implies " +
"an edit, state the edit you would make. Remark: " +
ask;
/** F6.x/Task 6: the injectable low-level turn runner behind respondInThread. */
type TurnRunner = (instruction: string, context: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
interface DocState {
docPath: string;
uri: vscode.Uri;
@@ -34,25 +52,45 @@ interface DocState {
live: Map<string, OffsetRange>;
/** thread id -> orphaned flag for the current render. */
orphaned: Map<string, boolean>;
/** Finding 2 (final whole-branch review): thread id -> in-flight offer-flow
* token ("offer" once a machine reply lands, "offerdone" once spent),
* tracked SEPARATELY from the open/resolved/orphaned status so setting one
* never clobbers the other — both are folded into one space-separated
* `contextValue` string by `applyContextValue` (package.json when-clauses
* match either token with an unanchored regex). Ephemeral: reset on every
* render (a fresh vsThread never carries a stale offer). */
offerToken: Map<string, "offer" | "offerdone">;
}
export class ThreadController implements vscode.Disposable {
private readonly controller: vscode.CommentController;
private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocState>(); // keyed by docPath
/** thread -> the pending machine "offer" (D8): the ask it answered, ready to become an edit. */
private readonly offers = new WeakMap<vscode.CommentThread, { ask: string; threadId: string }>();
/** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */
private turnRunner: TurnRunner | undefined;
/** Kept as ONE object; re-assigned on every registry change so VS Code re-queries
* (spec §6.4 v0.2.1 — reassignment is the API's only "ranges changed" signal). */
private readonly rangeProvider: vscode.CommentingRangeProvider = {
provideCommentingRanges: (document) => {
if (!isAuthorable(document.uri.scheme)) return [];
if (!this.registry.isCoediting(document.uri)) return [];
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
},
};
constructor(
private readonly store: SidecarRouter,
private readonly rootDir: string | undefined,
private readonly guard: VersionGuard,
private readonly registry: CoeditingRegistry,
private readonly editFlow: EditFlow,
private readonly liveProgressUi: LiveProgressUi,
) {
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
this.controller.commentingRangeProvider = {
provideCommentingRanges: (document) => {
if (!isAuthorable(document.uri.scheme)) return [];
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
},
};
this.controller.commentingRangeProvider = this.rangeProvider;
this.disposables.push(this.controller);
this.disposables.push(
@@ -64,8 +102,30 @@ export class ThreadController implements vscode.Disposable {
vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) =>
this.setThreadStatus(t, "open"),
),
// D19 (spec §6.4 v0.2.1): the comments-first "Ask Claude" gesture — a
// focused comment box, not a webview.
vscode.commands.registerCommand("cowriting.askClaude", () => this.askClaude()),
// D8: turn a machine "offer" thread into pending F4 proposal(s) (INV-5).
vscode.commands.registerCommand(
"cowriting.makeThreadEdit",
(arg: { thread: vscode.CommentThread } | vscode.CommentThread) => this.makeEditFromThreadArg(arg),
),
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
// INV-10 (PUC-7): force VS Code to re-query commenting ranges on every gate
// change, and hide/restore rendered threads — exit hides only (dispose the
// rendered vsThreads), re-enter restores (renderAll reloads from the
// sidecar, which is the durable source of truth regardless of gate state).
this.registry.onDidChange(({ uri, coediting }) => {
this.controller.commentingRangeProvider = this.rangeProvider;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (!doc) return;
if (coediting) {
this.renderAll(doc);
} else {
this.disposeRendered(this.keyOf(doc));
}
}),
);
}
@@ -100,17 +160,44 @@ export class ThreadController implements vscode.Disposable {
vsThreads: new Map(),
live: new Map(),
orphaned: new Map(),
offerToken: new Map(),
};
this.docs.set(docPath, state);
}
return state;
}
// ---- D19: comments-first ask -----------------------------------------------------
/**
* D19 (spec §6.4 v0.2.1): "Ask Claude" opens a focused comment box on the
* active editor's selection, or, when there is no selection, a top-anchored
* whole-document thread. The ask itself IS a comment — respondInThread (D10)
* takes it from there once the human submits it via cowriting.reply.
*/
async askClaude(): Promise<void> {
const ed = vscode.window.activeTextEditor;
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first.");
return;
}
if (ed.selection.isEmpty) {
// whole-document ask → top-anchored thread (D19)
ed.selection = new vscode.Selection(0, 0, 0, 0);
ed.revealRange(new vscode.Range(0, 0, 0, 0));
}
// Spike finding: workbench.action.addComment is the only path that opens the
// comment widget WITH its input focused (no thread.focus() API exists).
this.controller.commentingRangeProvider = this.rangeProvider; // defensive refresh
await vscode.commands.executeCommand("workbench.action.addComment");
}
// ---- PUC-1: create on selection -------------------------------------------------
async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
const editor = vscode.window.activeTextEditor;
if (!editor || editor.selection.isEmpty || !isAuthorable(editor.document.uri.scheme)) return undefined;
if (!this.registry.isCoediting(editor.document.uri)) return undefined;
if (this.guard.isReadOnly(this.keyOf(editor.document))) return undefined;
const document = editor.document;
const state = this.ensureState(document);
@@ -119,22 +206,175 @@ export class ThreadController implements vscode.Disposable {
end: document.offsetAt(editor.selection.end),
};
const fp = buildFingerprint(document.getText(), offsets);
const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody });
const author = this.currentAuthor();
const { threadId } = addThread(state.artifact, fp, { author, body: firstBody });
this.persist(state);
this.renderThread(document, state, threadId, offsets, false);
const vsThread = this.renderThread(document, state, threadId, offsets, false);
// D10: the first message of a new thread is also "a comment on a coedited
// doc" — run the same respond loop as a reply. Finding 2 guard: only for a
// human-authored message (never re-trigger the loop off the machine's own
// words — see reply() below for the matching guard).
if (author.kind !== "agent") {
void this.respondInThread(vsThread, state, threadId, firstBody);
}
return threadId;
}
// ---- PUC-2: reply / resolve -----------------------------------------------------
reply(r: vscode.CommentReply): void {
const threadId = this.threadIdOf(r.thread);
const state = this.stateOfThread(r.thread);
if (!threadId || !state) return;
const vsThread = r.thread;
if (!isAuthorable(vsThread.uri.scheme) || !this.registry.isCoediting(vsThread.uri)) return;
const document = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString());
if (!document) return;
const state = this.ensureState(document);
if (this.guard.isReadOnly(state.docPath)) return;
appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text });
const author = this.currentAuthor();
let threadId = this.threadIdOf(vsThread);
if (threadId) {
appendMessage(state.artifact, threadId, { author, body: r.text });
} else {
// D10/D19: a brand-new thread created via the native "+"/comment-widget
// gesture (not via createThreadOnSelection) — VS Code hands us a real,
// still-unregistered CommentThread; register it now.
const range = vsThread.range ?? new vscode.Range(0, 0, 0, 0);
const offsets: OffsetRange = {
start: document.offsetAt(range.start),
end: document.offsetAt(range.end),
};
const fp = buildFingerprint(document.getText(), offsets);
const created = addThread(state.artifact, fp, { author, body: r.text });
threadId = created.threadId;
state.vsThreads.set(threadId, vsThread);
state.live.set(threadId, offsets);
state.orphaned.set(threadId, false);
// Finding 2 (final whole-branch review): a thread born via the native
// "+" gesture skipped renderThread entirely, so it never got a status
// contextValue at all — Resolve/Reopen never appeared for it. Stamp one
// now, same as every other thread.
this.applyContextValue(state, threadId, vsThread);
}
this.persist(state);
this.refreshComments(r.thread, state, threadId);
this.refreshComments(vsThread, state, threadId);
// D10: every HUMAN comment on a coedited doc runs a turn. Finding 2 guard:
// `cowriting.reply` is the ingress for both a genuine human reply and (in
// principle) a re-dispatched machine message — never loop the machine's own
// reply back through respondInThread (it would double-turn on Claude's words
// and re-flip an already-"offer" thread).
if (author.kind !== "agent") {
void this.respondInThread(vsThread, state, threadId, r.text);
}
}
/**
* D10/PUC-8: run one reply turn for `ask`, then append the machine's reply
* (INV-8 onBehalfOf provenance) and flip the thread into an "offer" — ready
* for makeThreadEdit to cut it into pending proposal(s) (D8, INV-5).
*/
private async respondInThread(
vsThread: vscode.CommentThread,
state: DocState,
threadId: string,
ask: string,
): Promise<void> {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString());
if (!doc) return;
const contextText = this.threadContextText(doc, vsThread);
try {
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…", cancellable: true },
async (progress, token) => {
const run = this.turnRunner ?? (await import("./liveTurn")).runEditTurn;
const ui = this.liveProgressUi.begin(ask, progress, token);
let turn: EditTurnResult;
try {
turn = await run(REPLY_PROMPT(ask), contextText, { onProgress: ui.onProgress, signal: ui.signal });
} catch (err) {
if (token.isCancellationRequested) return;
throw err;
}
// machine reply, onBehalfOf provenance (INV-8)
appendMessage(state.artifact, threadId, {
author: {
kind: "agent",
id: "claude",
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId, onBehalfOf: this.currentAuthor() },
},
body: turn.replacement,
});
this.persist(state);
this.refreshComments(vsThread, state, threadId);
// Finding 2: fold "offer" in alongside the status token — see
// `applyContextValue` — instead of clobbering it (when-key:
// `commentThread =~ /\boffer\b/`).
state.offerToken.set(threadId, "offer");
this.applyContextValue(state, threadId, vsThread);
this.offers.set(vsThread, { ask, threadId });
},
);
} catch (err) {
void vscode.window.showErrorMessage(`Cowriting: Claude reply failed — ${err instanceof Error ? err.message : String(err)}`);
}
}
/** The passage a ranged thread discusses, or the whole document for a top-anchored (D19) thread. */
private threadContextText(doc: vscode.TextDocument, vsThread: vscode.CommentThread): string {
const range = vsThread.range;
if (range && !range.isEmpty) return doc.getText(range);
return doc.getText();
}
/** D19: a ranged thread targets its range; a top-anchored (whole-doc-ask) thread targets the document. */
private targetOf(doc: vscode.TextDocument, vsThread: vscode.CommentThread): EditTarget {
const range = vsThread.range;
if (!range || range.isEmpty) return { kind: "document" };
return { kind: "range", start: doc.offsetAt(range.start), end: doc.offsetAt(range.end) };
}
// ---- D8: offer -> pending proposal(s) (INV-5) -------------------------------------
private async makeEditFromThreadArg(arg: { thread: vscode.CommentThread } | vscode.CommentThread): Promise<void> {
const vsThread = "thread" in arg ? arg.thread : arg;
await this.runMakeEdit(vsThread);
}
private async runMakeEdit(vsThread: vscode.CommentThread): Promise<string[]> {
const offer = this.offers.get(vsThread);
if (!offer || !this.registry.isCoediting(vsThread.uri)) return [];
const doc = await vscode.workspace.openTextDocument(vsThread.uri);
const target = this.targetOf(doc, vsThread);
const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask);
// Finding 2 (final whole-branch review): fold "offerdone" in alongside the
// status token rather than clobbering it — see `applyContextValue`.
const state = this.stateOfThread(vsThread);
if (state) {
state.offerToken.set(offer.threadId, "offerdone");
this.applyContextValue(state, offer.threadId, vsThread);
}
// Finding 3 (seam hygiene): drop the offer once it's spent — otherwise the
// `makeThreadEdit(threadId, docPath)` test seam (or a stray second UI click,
// now dead per contextValue but still a valid direct call) could re-run the
// same edit a second time. A future offer on this thread is a fresh
// respondInThread() call, which re-populates the WeakMap entry.
this.offers.delete(vsThread);
if (ids.length) {
void vscode.window.showInformationMessage(
`Cowriting: ${ids.length} pending change${ids.length === 1 ? "" : "s"} landed in the buffer — ✓ Keep / ✗ Reject there.`,
);
}
return ids;
}
/** Test seam (Task 6): run the offer for a thread without the UI button. */
async makeThreadEdit(threadId: string, docPath: string): Promise<string[]> {
const vsThread = this.docs.get(docPath)?.vsThreads.get(threadId);
if (!vsThread) return [];
return this.runMakeEdit(vsThread);
}
/** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */
setTurnRunnerForTest(fn: TurnRunner): void {
this.turnRunner = fn;
}
private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void {
@@ -145,13 +385,14 @@ export class ThreadController implements vscode.Disposable {
setStatus(state.artifact, threadId, status);
this.persist(state);
vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
vsThread.contextValue = status;
this.applyContextValue(state, threadId, vsThread); // preserves any in-flight offer token
}
// ---- PUC-3/4: render, reload, re-anchor -----------------------------------------
/** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */
renderAll(document: vscode.TextDocument): void {
if (!this.registry.isCoediting(document.uri)) return;
const docPath = this.keyOf(document);
const state = this.ensureState(document);
// fresh artifact from disk (reload / external change)
@@ -160,6 +401,7 @@ export class ThreadController implements vscode.Disposable {
state.vsThreads.clear();
state.live.clear();
state.orphaned.clear();
state.offerToken.clear();
const text = document.getText();
for (const thread of state.artifact.threads) {
const fp = state.artifact.anchors[thread.anchorId]?.fingerprint;
@@ -225,7 +467,7 @@ export class ThreadController implements vscode.Disposable {
threadId: string,
offsets: OffsetRange,
orphaned: boolean,
): void {
): vscode.CommentThread {
const thread = state.artifact.threads.find((t) => t.id === threadId)!;
const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end));
const vsThread = this.controller.createCommentThread(
@@ -234,12 +476,34 @@ export class ThreadController implements vscode.Disposable {
thread.messages.map((m) => this.toComment(m.body, m.author.id)),
);
vsThread.label = orphaned ? "⚠ Orphaned thread (anchor not found)" : undefined;
vsThread.contextValue = orphaned ? "orphaned" : thread.status;
vsThread.state = thread.status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
vsThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed;
state.vsThreads.set(threadId, vsThread);
state.live.set(threadId, offsets);
state.orphaned.set(threadId, orphaned);
state.offerToken.delete(threadId); // a freshly-created vsThread never carries a stale offer
this.applyContextValue(state, threadId, vsThread);
return vsThread;
}
/**
* Finding 2 (final whole-branch review): `contextValue` is the ONE string
* VS Code's `comments/commentThread/*` `when`-clauses can key on, but two
* INDEPENDENT things need to show through it — the open/resolved/orphaned
* status (Resolve/Reopen) and the offer-flow state (makeThreadEdit). Folding
* both into one space-separated string (`"open offer"`, `"resolved"`, …)
* with package.json `when`s matched by an unanchored `=~ /\btoken\b/` regex
* lets either flip without erasing the other — setting the offer flag alone
* used to stomp the status token entirely (every human comment now triggers
* a reply, so every thread on a coedited doc lost its Resolve/Reopen menu
* the moment the machine answered).
*/
private applyContextValue(state: DocState, threadId: string, vsThread: vscode.CommentThread): void {
const orphaned = !!state.orphaned.get(threadId);
const thread = state.artifact.threads.find((t) => t.id === threadId);
const statusToken = orphaned ? "orphaned" : (thread?.status ?? "open");
const offerToken = state.offerToken.get(threadId);
vsThread.contextValue = offerToken ? `${statusToken} ${offerToken}` : statusToken;
}
private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void {
@@ -269,6 +533,18 @@ export class ThreadController implements vscode.Disposable {
return undefined;
}
/** PUC-7 (INV-10): exit hides only — dispose the rendered vsThreads for a doc
* without touching its sidecar-backed artifact (re-enter restores via renderAll). */
private disposeRendered(docPath: string): void {
const state = this.docs.get(docPath);
if (!state) return;
for (const vsThread of state.vsThreads.values()) vsThread.dispose();
state.vsThreads.clear();
state.live.clear();
state.orphaned.clear();
state.offerToken.clear();
}
// ---- test-facing surface --------------------------------------------------------
getRendered(docPath: string): RenderedThread[] {
@@ -283,6 +559,7 @@ export class ThreadController implements vscode.Disposable {
status: t.status,
orphaned: !!state.orphaned.get(id),
range: { startLine: r ? r.start.line : 0, endLine: r ? r.end.line : 0 },
contextValue: typeof vsThread.contextValue === "string" ? vsThread.contextValue : "",
});
}
return out;
+94 -36
View File
@@ -193,6 +193,30 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[]
return ops;
}
/**
* #64/Task 3 (INV-6): the number of line-grain hunks between `oldText` and
* `newText` — a run of one or more contiguous non-equal lines counts as ONE
* hunk (the status-bar "N changes" count). Same LCS walk `diffBlocks` uses
* (jsdiff `diffArrays`), at line grain instead of block grain. Pure,
* vscode-free, deterministic.
*/
export function countLineHunks(oldText: string, newText: string): number {
const before = oldText.split(/\r?\n/);
const after = newText.split(/\r?\n/);
const changes = diffArrays(before, after);
let hunks = 0;
let inHunk = false;
for (const ch of changes) {
if (ch.added || ch.removed) {
if (!inHunk) hunks++;
inHunk = true;
} else {
inHunk = false;
}
}
return hunks;
}
/** A contiguous changed region of `currentText` and its replacement (F11). */
export interface EditHunk {
/** char offset of the hunk's first changed char in currentText. */
@@ -445,8 +469,13 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
return defaultFence(tokens, idx, options, env, self);
};
/** Inner source of a ```mermaid fence (drops the fence lines), or null. */
function mermaidFenceBody(raw: string): string | null {
/**
* Inner source of a ```mermaid fence (drops the fence lines), or null. Exported
* so `previewAnnotations.ts` can pair a `diffBlocks` mermaid `changed` op's
* before/current fence bodies for `diffMermaid` without reinventing this
* extraction (Task 7 §2.6 parity).
*/
export function mermaidFenceBody(raw: string): string | null {
const lines = raw.split(/\r?\n/);
const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/);
if (!open || open[3]?.toLowerCase() !== "mermaid") return null;
@@ -455,8 +484,12 @@ function mermaidFenceBody(raw: string): string | null {
return body.join("\n");
}
/** F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block. */
const MERMAID_LEGEND =
/**
* F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block.
* Exported for reuse by `previewAnnotations.ts`'s built-in-preview mermaid
* diff wiring (Task 7 §2.6 parity — same legend markup, both surfaces).
*/
export const MERMAID_LEGEND =
'<div class="cw-mermaid-legend">' +
'<span class="cw-leg cw-leg-add">added</span>' +
'<span class="cw-leg cw-leg-chg">changed</span>' +
@@ -548,11 +581,14 @@ export interface RenderOptions {
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
render?: (src: string) => string;
/**
* #48: the baseline was just PINNED (`reason === "pinned"`). With zero changes
* since that pin, the on-render is fully clean — no authorship coloring — so a
* pin reads as "this is my clean starting point". Distinct from a baseline
* advanced by a machine-landing (accept), which keeps its authorship coloring
* (F10 INV-33). Only consulted by `renderReview`.
* #48: the baseline was just PINNED (`reason === "pinned"` — "Mark Changes as
* Reviewed" in snapshot mode). With zero changes since that pin, the
* on-render is fully clean — no authorship coloring — so a pin reads as
* "this is my clean starting point". Distinct from an ordinary accepted
* proposal, which — since the native-surfaces migration retired the
* machine-landing baseline advance (INV-18/INV-7) — stays a visible change-
* since-baseline and keeps its authorship coloring (F10 INV-33). Only
* consulted by `renderReview`.
*/
pinned?: boolean;
}
@@ -631,15 +667,33 @@ export interface AuthorSpan {
author: AuthorKind;
}
/**
* Task 7 (preview annotations, native-surfaces migration): the sentinel
* vocabulary generalizes beyond per-author spans to a plain "del" tag — a
* re-surfaced baseline deletion has no author variant (CSS carries one fixed
* `.cw-del`, the F10 vocabulary). `injectSentinels`/`sentinelsToSpans` are
* exported so `previewAnnotations.ts` can drive the SAME token-aware
* discipline (#33/#47) for its 3-way ins-claude/ins-human/del marks;
* `colorByAuthor` (F9/F10) keeps its narrower per-author call, unaffected.
*/
export type SentinelTag = AuthorKind | "del";
export interface TaggedSpan {
start: number;
end: number;
tag: SentinelTag;
}
// Private-Use-Area sentinels (never appear in real content; markdown-it passes
// them through as plain text). Paired open/close per author.
const SENT = {
// them through as plain text). Paired open/close per tag.
const SENT: Record<SentinelTag, { open: string; close: string }> = {
claude: { open: "", close: "" },
human: { open: "", close: "" },
} as const;
del: { open: "", close: "" },
};
const SENT_TAGS = Object.keys(SENT) as SentinelTag[];
function isCloseSentinel(m: string): boolean {
return m === SENT.claude.close || m === SENT.human.close;
return SENT_TAGS.some((t) => SENT[t].close === m);
}
// Markdown emphasis / code delimiters whose RUNS must never be split by an
@@ -685,8 +739,8 @@ function blockContentStart(raw: string): number {
return 0;
}
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
/** Inject paired sentinels into a block's raw text for the (tagged) spans clipped to it. */
export function injectSentinels(raw: string, blockStart: number, spans: TaggedSpan[]): string {
const inserts: { at: number; marker: string }[] = [];
// Open sentinels must not precede block-level markdown markers (heading/list/
// blockquote syntax at position 0) — a PUA char before "## " prevents markdown-it
@@ -696,8 +750,8 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
const lo = Math.max(minOpen, clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart)));
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
inserts.push({ at: lo, marker: SENT[s.author].open });
inserts.push({ at: hi, marker: SENT[s.author].close });
inserts.push({ at: lo, marker: SENT[s.tag].open });
inserts.push({ at: hi, marker: SENT[s.tag].close });
}
// Apply high offset → low so earlier offsets stay valid. At an equal offset
// (one span's close == the next's open), apply opens BEFORE closes so the
@@ -708,34 +762,34 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
return out;
}
const SENTINEL_OF: Record<string, { author: AuthorKind; open: boolean } | undefined> = {
[SENT.claude.open]: { author: "claude", open: true },
[SENT.claude.close]: { author: "claude", open: false },
[SENT.human.open]: { author: "human", open: true },
[SENT.human.close]: { author: "human", open: false },
};
const ALL_SENTINELS = new RegExp(
`[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`,
"g",
);
const SENTINEL_OF: Record<string, { tag: SentinelTag; open: boolean } | undefined> = {};
for (const tag of SENT_TAGS) {
SENTINEL_OF[SENT[tag].open] = { tag, open: true };
SENTINEL_OF[SENT[tag].close] = { tag, open: false };
}
const ALL_SENTINELS = new RegExp(`[${SENT_TAGS.map((t) => SENT[t].open + SENT[t].close).join("")}]`, "g");
/**
* #33: token-aware replacement of the rendered author sentinels with `cw-ins-*`
* spans. A naive global string-replace (the old approach) could emit a span that
* #33: token-aware replacement of the rendered sentinels with `cw-*` spans. A
* naive global string-replace (the old approach) could emit a span that
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
* emits the author span only around TEXT runs, CLOSING it before any `<tag>` and
* emits the span only around TEXT runs, CLOSING it before any `<tag>` and
* REOPENING it after, so a span is always well-nested within the inline elements
* (one `<span>` segment per text run). Tags are copied verbatim (with any stray
* sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic.
* sentinel stripped, so no Private-Use-Area char ever leaks). `kind` picks the
* author-class prefix (`cw-by-*`/`cw-ins-*`); the tag-less "del" mark (Task 7,
* no author variant) always renders the fixed `cw-del` regardless of `kind`.
* Pure, deterministic.
*/
function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
export function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
const out: string[] = [];
let current: AuthorKind | null = null; // which author region we're inside
let current: SentinelTag | null = null; // which region we're inside
let spanOpen = false; // whether a <span> is currently open in `out`
const classFor = (tag: SentinelTag): string => (tag === "del" ? "cw-del" : `cw-${kind}-${tag}`);
const openSpan = () => {
if (current && !spanOpen) {
out.push(`<span class="cw-${kind}-${current}">`);
out.push(`<span class="${classFor(current)}">`);
spanOpen = true;
}
};
@@ -749,7 +803,7 @@ function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
const ch = html[i];
const sentinel = SENTINEL_OF[ch];
if (sentinel) {
if (sentinel.open) current = sentinel.author; // span opens lazily before the next text char
if (sentinel.open) current = sentinel.tag; // span opens lazily before the next text char
else {
closeSpan();
current = null;
@@ -786,7 +840,11 @@ export function colorByAuthor(
kind: "by" | "ins" = "by",
): string {
const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length);
const injected = injectSentinels(raw, blockStart, overlapping);
const injected = injectSentinels(
raw,
blockStart,
overlapping.map((s) => ({ start: s.start, end: s.end, tag: s.author })),
);
return sentinelsToSpans(render(injected), kind);
}
-537
View File
@@ -1,537 +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, diffToBlockHunks, landedTextOf, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer";
import { isAuthorable } from "./workspacePath";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi";
import { promptEditInstruction } from "./editInstructionInput";
/**
* F11: a host edit turn (selection/document text + instruction → rewrite).
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
* the arg is optional so existing test stubs that ignore it stay valid.
*/
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
const VIEW_TYPE = "cowriting.trackChangesPreview";
const DEBOUNCE_MS = 150;
/**
* Inbound webview→host messages (intent only — the sealed webview never mutates,
* INV-21/35). F10 carried the annotations toggle + ✓/✗ proposal decisions; F11
* adds the toolbar intents (pin baseline / ask Claude).
*/
type ToolbarMsg =
| { type: "setMode"; mode: "on" | "off" }
| { type: "accept"; proposalId: string }
| { type: "reject"; proposalId: string }
| { type: "pinBaseline" }
| { type: "askClaude"; scope: "document" }
| { type: "askClaude"; scope: "selection"; start: number; end: number }
| { 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);
/**
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
*/
private editTurn: EditTurn = async (instruction, text, opts) => {
const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text, opts);
};
/**
* The instruction prompt (the multi-line split-below webview box) for BOTH the
* selection and document cases. A field so host E2E can stub it — the webview
* DOM can't run in CI (mirrors `editTurn`). Also used by the editor's
* `cowriting.editSelection` command (via this controller).
*/
askEditInstruction: (header: string) => Promise<string | undefined> = promptEditInstruction;
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0;
private nextTurnSeq(): number {
return ++this.turnSeq;
}
constructor(
private readonly diffView: DiffViewController,
private readonly extensionUri: vscode.Uri,
private readonly attribution: AttributionController,
private readonly proposals: ProposalController,
private readonly liveProgressUi: LiveProgressUi,
) {
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);
}),
// F11: document-scoped Ask-Claude (also reused by #42's reach gateways).
// Edits a markdown doc; the rewrite is diffed into F4 proposals.
// #42 (INV-38): the editor/title/context (tab) entry passes the clicked
// tab's resource Uri — target THAT document, opening it if it isn't already
// an open buffer (mirrors showTrackChangesPreview's #41 resolution); the
// palette / keybinding / editor/context pass nothing → the active editor.
vscode.commands.registerCommand("cowriting.editDocument", 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 || !this.isMarkdown(doc)) {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
return;
}
void this.askClaude(doc, { kind: "document" });
}),
vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
this.proposals.onDidChangeProposals(({ uri }) => {
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.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"}.`,
);
}
}
/**
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
*/
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
// Both scopes use the same multi-line split-below webview box; only the header
// (and the downstream proposal logic) differs. For a selection the document
// above keeps the selection highlighted while the box is open.
const header =
target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection";
const instruction = await this.askEditInstruction(header);
if (!instruction) return;
try {
const ids = await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const ui = this.liveProgressUi.begin(instruction, progress, token);
try {
return await this.runEditAndPropose(document, target, instruction, {
onProgress: ui.onProgress,
signal: ui.signal,
});
} catch (err) {
// #60 (INV-47): a user cancel proposes nothing (the benign empty path).
if (token.isCancellationRequested) return [] as string[];
throw err;
}
},
);
if (ids.length === 0) {
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
} else {
void vscode.window.showInformationMessage(
`Cowriting: Claude proposed ${ids.length} edit${ids.length === 1 ? "" : "s"} — review ✓/✗ in the preview.`,
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
}
}
/**
* F11/F12 (INV-35/39): run one host edit turn and record the result as F4
* proposal(s) — a SELECTION yields one single-range proposal over the resolved
* block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per
* changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged
* `granularity:"block"` so accept reconciles attribution per word (INV-40).
* Never mutates the document (INV-10). Returns the created proposal ids.
*/
async runEditAndPropose(
document: vscode.TextDocument,
target: EditTarget,
instruction: string,
opts?: RunEditTurnOptions,
): Promise<string[]> {
const full = document.getText();
// One turnId per gesture — the document case's N hunk-proposals all share it,
// so a single rewrite groups as one agent turn (parity with editSelection).
const turnId = `turn-${this.nextTurnSeq()}`;
const provenance = (turn: EditTurnResult) =>
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
if (target.kind === "range") {
const selected = full.slice(target.start, target.end);
const turn = await this.editTurn(instruction, selected, opts);
if (turn.replacement === "" || turn.replacement === selected) return [];
const fp = buildFingerprint(full, { start: target.start, end: target.end });
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
return id ? [id] : [];
}
const turn = await this.editTurn(instruction, full, opts);
const ids: string[] = [];
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
// granularity — one proposal per changed block (the unit a human reviews) —
// not per word. Each is tagged `granularity:"block"` so accept reconciles
// attribution per word inside the block (INV-40).
for (const h of diffToBlockHunks(full, turn.replacement)) {
const fp = buildFingerprint(full, { start: h.start, end: h.end });
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), {
turnId,
instruction,
granularity: "block",
});
if (id) ids.push(id);
}
return ids;
}
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 "opened (no baseline yet)";
const time = new Date(baseline.capturedAt).toLocaleTimeString();
switch (baseline.reason) {
case "machine-landing":
return `Claude landed ${time}`;
case "pinned":
return `pinned ${time}`;
default:
return `opened ${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 test seam: stub the host edit turn so the document/selection paths run without an LLM. */
setEditTurnForTest(fn: EditTurn): void {
this.editTurn = fn;
}
/**
* F11 (PUC-1/7): whether the previewed doc's edit controls (Pin + Ask-Claude)
* are enabled — true only for an authorable doc. The annotations toggle is
* always active (reading is always allowed). False if no panel/doc.
*/
editControlsEnabled(uriString: string): boolean {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
return doc ? isAuthorable(doc.uri.scheme) : false;
}
getLastModel(uriString: string): BlockOp[] | undefined {
return this.lastModel.get(uriString);
}
/** 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();
}
}
+39 -3
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { BaselineStore, type Baseline } from "../src/baselineStore";
import { BaselineStore, normalizeReason, type Baseline } from "../src/baselineStore";
let dir: string;
@@ -16,9 +16,23 @@ afterEach(() => {
const KEY = "a1b2c3"; // a stand-in for the controller's sha256(uri) key
function sample(uri = "file:///ws/notes/chapter-1.md"): Baseline {
return { uri, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" };
return { uri, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "entered" };
}
describe("normalizeReason (INV-7 legacy migration)", () => {
it("migrates legacy 'opened' → 'entered'", () => {
expect(normalizeReason("opened")).toBe("entered");
});
it("migrates legacy 'machine-landing' → 'entered'", () => {
expect(normalizeReason("machine-landing")).toBe("entered");
});
it("passes current reasons through unchanged", () => {
expect(normalizeReason("entered")).toBe("entered");
expect(normalizeReason("pinned")).toBe("pinned");
expect(normalizeReason("head")).toBe("head");
});
});
describe("BaselineStore", () => {
it("returns null for a key with no baseline", () => {
expect(new BaselineStore(dir).load(KEY)).toBeNull();
@@ -36,9 +50,31 @@ describe("BaselineStore", () => {
expect(store.load(KEY)).toEqual(b);
});
it("migrates a legacy on-disk reason ('opened') to 'entered' on load", () => {
const store = new BaselineStore(dir);
fs.mkdirSync(path.dirname(store.baselinePath(KEY)), { recursive: true });
fs.writeFileSync(
store.baselinePath(KEY),
JSON.stringify({ uri: "file:///a.md", text: "legacy\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }),
"utf8",
);
expect(store.load(KEY)?.reason).toBe("entered");
});
it("migrates a legacy on-disk reason ('machine-landing') to 'entered' on load", () => {
const store = new BaselineStore(dir);
fs.mkdirSync(path.dirname(store.baselinePath(KEY)), { recursive: true });
fs.writeFileSync(
store.baselinePath(KEY),
JSON.stringify({ uri: "file:///a.md", text: "legacy\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "machine-landing" }),
"utf8",
);
expect(store.load(KEY)?.reason).toBe("entered");
});
it("overwrites in place: the newest epoch wins, no history kept", () => {
const store = new BaselineStore(dir);
store.save(KEY, { uri: "untitled:Untitled-1", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" });
store.save(KEY, { uri: "untitled:Untitled-1", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "entered" });
store.save(KEY, { uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
expect(store.load(KEY)).toEqual({ uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
});
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from "vitest";
// The pure set logic lives in a vscode-free helper the class wraps, so unit-test the class
// with a Memento stub (vitest runs vscode-free; the module imports vscode only for types +
// EventEmitter — mock it).
vi.mock("vscode", () => ({
EventEmitter: class {
private handlers: Array<(e: unknown) => void> = [];
event = (h: (e: unknown) => void) => { this.handlers.push(h); return { dispose() {} }; };
fire(e: unknown) { for (const h of this.handlers) h(e); }
dispose() {}
},
Uri: { parse: (s: string) => ({ toString: () => s }) },
commands: { executeCommand: vi.fn() },
window: { activeTextEditor: undefined },
}));
import * as vscode from "vscode";
import { CoeditingRegistry } from "../src/coeditingRegistry";
function memento(): { store: Map<string, unknown> } & { get: any; update: any } {
const store = new Map<string, unknown>();
return {
store,
get: (k: string, dflt?: unknown) => (store.has(k) ? store.get(k) : dflt),
update: (k: string, v: unknown) => { store.set(k, v); return Promise.resolve(); },
} as any;
}
describe("CoeditingRegistry", () => {
it("enter/exit flips membership and fires onDidChange", () => {
const reg = new CoeditingRegistry(memento() as any);
const events: Array<{ uri: string; coediting: boolean }> = [];
reg.onDidChange((e) => events.push(e as any));
const uri = { toString: () => "file:///a.md" } as any;
expect(reg.isCoediting(uri)).toBe(false);
reg.enter(uri);
expect(reg.isCoediting(uri)).toBe(true);
reg.exit(uri);
expect(reg.isCoediting(uri)).toBe(false);
expect(events).toEqual([
{ uri: "file:///a.md", coediting: true },
{ uri: "file:///a.md", coediting: false },
]);
});
it("persists the set across construction (reload survival)", () => {
const m = memento();
const reg1 = new CoeditingRegistry(m as any);
reg1.enter({ toString: () => "file:///a.md" } as any);
const reg2 = new CoeditingRegistry(m as any);
expect(reg2.isCoediting({ toString: () => "file:///a.md" } as any)).toBe(true);
expect(reg2.list()).toEqual(["file:///a.md"]);
});
it("enter is idempotent (no duplicate events)", () => {
const reg = new CoeditingRegistry(memento() as any);
const events: unknown[] = [];
reg.onDidChange((e) => events.push(e));
const uri = { toString: () => "file:///a.md" } as any;
reg.enter(uri);
reg.enter(uri);
expect(events).toHaveLength(1);
});
it("exit is idempotent (no duplicate events on double exit)", () => {
const reg = new CoeditingRegistry(memento() as any);
const events: unknown[] = [];
reg.onDidChange((e) => events.push(e));
const uri = { toString: () => "file:///a.md" } as any;
reg.enter(uri);
reg.exit(uri);
reg.exit(uri);
expect(events).toHaveLength(2); // enter, then exit (second exit fires nothing)
});
it("syncContext: active editor with coediting doc sets isCoediting=true", () => {
const reg = new CoeditingRegistry(memento() as any);
const uri = { toString: () => "file:///a.md" } as any;
reg.enter(uri);
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
const editor = { document: { uri } } as any;
reg.syncContext(editor);
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.isCoediting", true);
});
it("syncContext: undefined editor sets isCoediting=false", () => {
const reg = new CoeditingRegistry(memento() as any);
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
reg.syncContext(undefined);
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.isCoediting", false);
});
it("syncContext: non-coediting doc sets isCoediting=false", () => {
const reg = new CoeditingRegistry(memento() as any);
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
const uri = { toString: () => "file:///nonexistent.md" } as any;
const editor = { document: { uri } } as any;
reg.syncContext(editor);
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.isCoediting", false);
});
it("syncContext: baselineMode defaults to empty string when not coediting", () => {
const reg = new CoeditingRegistry(memento() as any);
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
const uri = { toString: () => "file:///a.md" } as any;
const editor = { document: { uri } } as any;
reg.syncContext(editor);
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.baselineMode", "");
});
it("syncContext: baselineMode uses hook result when coediting and hook is set", () => {
const reg = new CoeditingRegistry(memento() as any);
const uri = { toString: () => "file:///a.md" } as any;
reg.enter(uri);
reg.baselineModeOf = vi.fn((): "head" | "snapshot" | undefined => "snapshot");
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
const editor = { document: { uri } } as any;
reg.syncContext(editor);
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.baselineMode", "snapshot");
});
it("syncContext: baselineMode defaults to empty string when hook returns undefined", () => {
const reg = new CoeditingRegistry(memento() as any);
const uri = { toString: () => "file:///a.md" } as any;
reg.enter(uri);
reg.baselineModeOf = vi.fn((): "head" | "snapshot" | undefined => undefined);
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
const editor = { document: { uri } } as any;
reg.syncContext(editor);
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.baselineMode", "");
});
it("syncContext: calls executeCommand twice (isCoediting and baselineMode)", () => {
const reg = new CoeditingRegistry(memento() as any);
const uri = { toString: () => "file:///a.md" } as any;
reg.enter(uri);
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
const editor = { document: { uri } } as any;
reg.syncContext(editor);
expect(mockExecuteCommand).toHaveBeenCalledTimes(2);
});
});
+77
View File
@@ -0,0 +1,77 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
import { settleUntil } from "../suite/helpers";
/**
* Task 9 (spec §7.1 rung 3 — the real @cline/sdk, manual gate). Drives the
* SAME comments-first ask -> reply -> offer -> proposal loop as
* test/e2e/suite/commentLoop.test.ts / fullLoop.test.ts, but with BOTH turns
* left UNSTUBBED: ThreadController's reply loop and EditFlow's edit turn hit
* the real local `claude` login (INV-8 -- the extension itself holds no
* credentials). Not part of `npm run test:e2e` / CI -- driven only by
* `scripts/smoke-native-loop.mjs` (`npm run smoke:native`), mirroring
* `scripts/smoke-live-turn.mjs`'s "manual gate, real SDK" role for F3.
*
* There is no VS Code API to drive the native comment-widget's OWN submit
* gesture from outside the UI (see commentLoop.test.ts's "Finding 1" note) --
* this calls the exact seam a real comment submission reaches
* (ThreadController.createThreadOnSelection / .makeThreadEdit), which is the
* closest a headless script gets to "post a real comment" on a live doc.
*/
export async function run(): Promise<void> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin");
if (!ext) throw new Error("extension not found -- is extensionDevelopmentPath wired correctly?");
const api = (await ext.activate()) as CowritingApi;
if (!api?.threadController || !api?.editFlow || !api?.proposalController || !api?.sidecarRouter) {
throw new Error("extension did not export the expected controllers");
}
const folder = vscode.workspace.workspaceFolders?.[0];
if (!folder) throw new Error("no workspace folder -- launch with the sandbox/ folder open");
const target = path.join(folder.uri.fsPath, `.smoke-native-loop-${process.pid}.md`);
const ORIGINAL = "The quick brown fox jumps over the lazy dog paragraph.";
fs.writeFileSync(target, `# Native-loop smoke\n\n${ORIGINAL}\n`, "utf8");
try {
const uri = vscode.Uri.file(target);
const doc = await vscode.workspace.openTextDocument(uri);
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
const docKey = api.proposalController.keyFor(doc);
const start = doc.getText().indexOf(ORIGINAL);
ed.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + ORIGINAL.length));
console.log("[smoke-native-loop] posting a real comment (createThreadOnSelection)...");
const threadId = await api.threadController.createThreadOnSelection(
"Rewrite this sentence to be more concise.",
);
if (!threadId) throw new Error("createThreadOnSelection refused (not coediting, or no selection)");
console.log(`[smoke-native-loop] thread created: ${threadId} -- waiting for the real @cline/sdk reply (this calls the live SDK; may take a while)...`);
await settleUntil(() => {
const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === threadId);
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
}, 180000);
console.log("[smoke-native-loop] reply landed -- thread flipped to \"offer\". Making the edit (real SDK)...");
const ids = await api.threadController.makeThreadEdit(threadId, docKey);
if (ids.length === 0) throw new Error("makeThreadEdit produced no proposals (the turn returned an empty/no-op replacement)");
console.log(`[smoke-native-loop] proposal id(s) created: ${ids.join(", ")}`);
await settleUntil(() => ids.every((id) => api.proposalController.isApplied(docKey, id)), 10000);
for (const id of ids) {
const ok = await api.proposalController.finalizeInPlace(docKey, id);
console.log(`[smoke-native-loop] accepted proposal ${id}: ${ok}`);
}
console.log(`[smoke-native-loop] PASS -- ${ids.length} proposal(s) created and accepted: ${ids.join(", ")}`);
} finally {
try {
fs.rmSync(target, { force: true });
} catch {
// best-effort cleanup -- never let cleanup mask the real result
}
}
}
@@ -79,22 +79,25 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
// F6 (#19) baseline data layer is workspace-INDEPENDENT: it captures a baseline
// for an untitled buffer even with no folder open (the two-pane VIEW was
// removed in #34; only the data layer remains). pinDiffBaseline stays real.
// removed in #34; only the data layer remains). markReviewed stays real.
// Native-surfaces migration (Task 2, INV-7): the baseline is established on
// coediting ENTRY, not merely on open.
test("F6 baseline data layer works with no folder open (untitled buffer)", async () => {
const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline registered");
assert.ok(all.includes("cowriting.markReviewed"), "markReviewed registered");
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
const untitled = await vscode.workspace.openTextDocument({ content: "no-folder scratch\n", language: "markdown" });
await vscode.window.showTextDocument(untitled);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await new Promise((r) => setTimeout(r, 300));
const key = untitled.uri.toString();
const baseline = api.diffViewController.getBaseline(key);
assert.ok(baseline, "baseline captured for the untitled buffer with no folder");
assert.strictEqual(baseline!.reason, "opened");
// pin resets the baseline to now — works folder-less.
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
assert.strictEqual(baseline!.reason, "entered");
// markReviewed resets the baseline to now — works folder-less.
await vscode.commands.executeCommand("cowriting.markReviewed");
await new Promise((r) => setTimeout(r, 300));
assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "pin works with no folder");
assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "markReviewed works with no folder");
});
});
+3
View File
@@ -18,6 +18,9 @@ async function openDoc(): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
// INV-10: attribution tracking is gated on coediting (Task 4) — idempotent
// across this order-dependent suite's repeated openDoc() calls.
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc;
}
async function getApi(): Promise<CowritingApi> {
+18 -14
View File
@@ -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,38 +11,39 @@ 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;
}
// F10 host E2E (no LLM): the rewrite of the obsolete F9 authorship-mode test.
// F9's "authorship" mode / renderAuthorship is gone — the on-state renderReview
// now author-colors Claude's PENDING proposal blocks as cw-ins-claude. After the
// proposal is accepted the baseline advances (INV-18/F12) and the text becomes
// "unchanged" → renders plain. The class to check is cw-ins-claude (proposal block),
// NOT cw-by-claude (the old F9 authorship render). Owns its own markdown doc.
// now author-colors Claude's PENDING proposal blocks as cw-ins-claude. This
// 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 / 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");
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
// INV-10: entering coediting is required for attribution tracking + F12
// optimistic-apply to fire — this suite never entered it (pre-migration).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
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", {
@@ -59,13 +61,15 @@ 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",
);
// accept via the seam — baseline advances (machine-landing, INV-18/F12)
// accept via the seam — the proposal lands; the baseline no longer auto-advances
// (INV-18/#48 retired, spec INV-7), but that isn't asserted here (no coediting
// entry / baseline established for this doc — only the attribution data layer).
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle();
+87
View File
@@ -0,0 +1,87 @@
import * as assert from "node:assert";
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as vscode from "vscode";
import { activateApi, settle, settleUntil } from "./helpers";
const WS = process.env.E2E_WORKSPACE!;
suite("baseline router (PUC-1, INV-7/D13/D14)", () => {
test("snapshot mode: enter captures; markReviewed re-pins clean", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "one\n" });
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
assert.strictEqual(api.diffViewController.modeOf(doc.uri.toString()), "snapshot");
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "one\n");
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "two\n"));
// Task 3 (INV-13): the status-bar/SCM change count tracks the live edit.
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 1);
await vscode.commands.executeCommand("cowriting.markReviewed");
const b = api.diffViewController.getBaseline(doc.uri.toString());
assert.strictEqual(b?.text, "one\ntwo\n");
assert.strictEqual(b?.reason, "pinned");
// A pin re-baselines to the clean current text, so the change count resets.
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 0);
});
test("head mode: baseline = HEAD; commit advances it", async function () {
this.timeout(30000);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-git-"));
const git = (...a: string[]) => execFileSync("git", ["-C", dir, ...a], { encoding: "utf8" });
git("init", "-q");
fs.writeFileSync(path.join(dir, "doc.md"), "committed\n");
git("add", "doc.md");
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "c1");
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(path.join(dir, "doc.md")));
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settleUntil(() => api.diffViewController.modeOf(doc.uri.toString()) === "head", 10000);
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "committed\n");
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "uncommitted\n"));
await doc.save();
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-aqm", "c2");
await settleUntil(
() => api.diffViewController.getBaseline(doc.uri.toString())?.text === "committed\nuncommitted\n",
15000,
);
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.reason, "head");
});
// Finding 3 (final whole-branch review, session native-surfaces-exec):
// DiffViewController only establishes a baseline on the registry's ENTER
// transition (a doc it can find already open) and, at startup, for docs
// already open at that moment. A registry member entered while its document
// was NOT yet open (the real-world case: a persisted coediting set restored
// after a window reload, whose member is only lazily opened later) used to
// never get a baseline at all — reproduced here directly via
// `coeditingRegistry.enter(uri)` on a URI with no open document yet (the
// registry API doesn't require one), then a genuine later open.
test("a registry member entered before its doc was open gets its baseline established once the doc IS opened", async () => {
const api = await activateApi();
const rel = "docs/finding3-establish-on-open.md";
const abs = path.join(WS, rel);
const content = "# late open\n\nestablish-on-open should catch this.\n";
fs.writeFileSync(abs, content, "utf8");
const uri = vscode.Uri.file(abs);
// Membership without an open document — DiffViewController's enter-
// transition handler no-ops (`textDocuments.find` misses), reproducing
// the gap: a coediting member with no mode/baseline yet.
api.coeditingRegistry.enter(uri);
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), true, "sanity: registry membership recorded");
assert.strictEqual(api.diffViewController.modeOf(uri.toString()), undefined, "sanity: the gap is reproduced");
// A genuine later open — the fix's `renderIfOpen`/`onDidOpenTextDocument`
// path must establish the baseline it missed.
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settleUntil(() => api.diffViewController.modeOf(uri.toString()) !== undefined, 10000);
assert.strictEqual(api.diffViewController.modeOf(uri.toString()), "snapshot");
assert.strictEqual(api.diffViewController.getBaseline(uri.toString())?.text, content);
});
});
+223
View File
@@ -0,0 +1,223 @@
import * as assert from "node:assert";
import * as fs from "node:fs";
import * as path from "node:path";
import MarkdownIt from "markdown-it";
import * as vscode from "vscode";
import { cowritingMarkdownItPlugin } from "../../../src/previewAnnotations";
import { activateApi, settle, settleUntil } from "./helpers";
const WS = process.env.E2E_WORKSPACE!;
async function api() {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
return (await ext.activate()) as import("../../../src/extension").CowritingApi;
}
suite("coediting registry (PUC-7)", () => {
test("enter → isCoediting true; exit → false; persists in list()", async () => {
const { coeditingRegistry } = await api();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# t\n\nbody\n" });
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), true);
assert.ok(coeditingRegistry.list().includes(doc.uri.toString()));
await vscode.commands.executeCommand("cowriting.stopCoediting");
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), false);
});
// Task 3 (INV-10): a document that was never entered into coediting gets no
// native-diff surface at all. QuickDiff's `provideOriginalResource` isn't
// directly queryable from a host-E2E test, so this asserts the gate INPUTS
// structurally: `isCoediting` stays false and the SCM change count — which
// `ScmSurfaceController` only ever populates for a coediting doc — stays 0.
test("non-entered doc: no coediting, no change count (structural, INV-10)", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "untouched\n" });
await vscode.window.showTextDocument(doc);
await settle();
assert.strictEqual(api.coeditingRegistry.isCoediting(doc.uri), false);
assert.strictEqual(api.scmSurfaceController.changeCount(doc.uri.toString()), 0);
});
// Task 4 (INV-10, PUC-7): the no-hijack scenario — a non-entered doc gets no
// thread-creation surface at all; entering unlocks it; exiting hides the
// rendered thread (not the sidecar-backed data); re-entering restores it.
test("non-entered doc gets no surfaces; exit detaches; re-enter restores threads", async () => {
const api = await activateApi();
// Task 6 (D10): createThreadOnSelection now also fires the respond-in-
// thread turn once the doc is coediting — stub it off (not under test
// here; this suite asserts render/hide/restore, not the reply loop).
api.threadController.setTurnRunnerForTest(async () => {
throw new Error("coediting suite: reply-loop turn stubbed off");
});
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# a\n\npara\n" });
const ed = await vscode.window.showTextDocument(doc);
// not entered → no thread creation
ed.selection = new vscode.Selection(2, 0, 2, 4);
const before = await api.threadController.createThreadOnSelection("hi");
assert.strictEqual(before, undefined);
// entered → works
await vscode.commands.executeCommand("cowriting.coeditDocument");
const id = await api.threadController.createThreadOnSelection("hi");
assert.ok(id);
// exit → rendered thread set empty; re-enter → restored from sidecar
await vscode.commands.executeCommand("cowriting.stopCoediting");
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 0);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 1);
});
// PUC-7 restore-on-enter gap: unlike ThreadController (Task 4, above),
// ProposalController.renderAll and AttributionController.loadAll were never
// wired to registry.onDidChange — so a document's FIRST enter into
// coediting with pre-existing sidecar proposals/attribution showed threads
// but not proposal decorations/CodeLens or committed author-coloring until
// a later edit happened to fire renderAll/loadAll. A same-session
// exit-then-re-enter doesn't reproduce this on its own (nothing clears the
// in-memory render caches on exit), so this test wipes those caches after
// exit — simulating "never rendered in this session yet" — then re-enters
// and asserts BOTH surfaces restore via the registry.onDidChange path
// alone, with no subsequent edit.
test("re-enter after exit restores proposal + attribution surfaces without an edit (PUC-7)", async () => {
const api = await activateApi();
// A workspace FILE (not untitled) — attribution only persists to the
// sidecar on save, and this test needs the sidecar (not just in-memory
// live state) to hold the seeded data before re-entering.
const rel = "docs/puc7-restore.md";
const abs = path.join(WS, rel);
fs.writeFileSync(abs, "# t\n\nfoo bar baz\n", "utf8");
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
// Seed a pending proposal (F4) + a committed agent-attributed edit (F3) —
// the two surfaces the gap left un-restored.
const target = "bar";
const start = doc.getText().indexOf(target);
const proposalId = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + target.length,
newText: "BAR-PROPOSED",
model: "sonnet",
sessionId: "e2e-puc7",
turnId: "turn-puc7",
});
assert.ok(proposalId, "propose returns an id");
// A real text change (not a no-op replace) — "foo" precedes the "bar"
// target above, so it is unaffected by F12's optimistic-apply of the
// proposal into the buffer.
const fooStart = doc.getText().indexOf("foo");
const applied = await api.attributionController.applyAgentEdit(
doc,
new vscode.Range(doc.positionAt(fooStart), doc.positionAt(fooStart + "foo".length)),
"FOO-CLAUDE",
{ kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-puc7" } },
{ turnId: "turn-puc7" },
);
assert.strictEqual(applied, true, "seam edit applies");
await settle();
await doc.save(); // persists attribution records to the sidecar (F3 PUC-4)
const key = api.proposalController.keyFor(doc);
assert.ok(api.proposalController.getRendered(key).length >= 1, "sanity: proposal rendered pre-exit");
assert.ok(api.attributionController.getSpans(key).length >= 1, "sanity: attribution rendered pre-exit");
await vscode.commands.executeCommand("cowriting.stopCoediting");
// Wipe the controllers' in-memory render caches for this doc — nothing in
// production does this on exit (by design: exit hides, it does not wipe
// sidecar-backed state), so this reflection reproduces the "never
// rendered this session" precondition of a genuinely fresh first-enter.
(api.proposalController as unknown as { docs: Map<string, unknown> }).docs.delete(key);
(api.attributionController as unknown as { docs: Map<string, unknown> }).docs.delete(key);
assert.strictEqual(api.proposalController.getRendered(key).length, 0, "cache cleared pre-re-enter");
assert.strictEqual(api.attributionController.getSpans(key).length, 0, "cache cleared pre-re-enter");
// Re-enter — the registry.onDidChange subscribers (PUC-7 fix) must
// restore both surfaces from the sidecar with no subsequent edit.
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settleUntil(
() => api.proposalController.getRendered(key).length >= 1 && api.attributionController.getSpans(key).length >= 1,
);
assert.strictEqual(api.proposalController.getRendered(key).length, 1, "proposal restored on re-enter");
assert.ok(api.attributionController.getSpans(key).length >= 1, "attribution restored on re-enter");
});
// Finding 1 (final whole-branch review, session native-surfaces-exec): the
// preview-annotation host's INV-10 gate must never leak one document's
// baseline/spans into another document's preview. The built-in preview's
// rendered DOM isn't queryable from a host E2E test (§6.8, mirrored from
// Task 7's own previewAnnotations.test.ts), so this drives the ACTUAL
// production `previewAnnotationHost` through a real markdown-it instance
// and asserts on the rendered HTML.
test("INV-10: a non-coedited doc's preview never inherits another doc's annotations", async () => {
const api = await activateApi();
const docA = await vscode.workspace.openTextDocument({ language: "markdown", content: "alpha original\n" });
await vscode.window.showTextDocument(docA);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const start = docA.getText().indexOf("original");
const okA = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
uri: docA.uri.toString(),
start,
end: start + "original".length,
newText: "REPLACED",
model: "sonnet",
sessionId: "e2e-inv10-leak",
turnId: "turn-e2e-inv10-leak",
});
assert.strictEqual(okA, true, "seam edit applies to docA");
await settle();
// docB is opened (becomes the active editor, so it would win any
// `lastCoeditedUri` fallback if the gate were wrong) but NEVER entered
// into coediting.
const docB = await vscode.workspace.openTextDocument({ language: "markdown", content: "bravo untouched\n" });
await vscode.window.showTextDocument(docB);
await settle();
const md = new MarkdownIt();
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
const html = md.render(docB.getText(), { currentDocument: docB.uri });
assert.ok(!html.includes("cw-ins-claude"), `docB must render with no annotations, got:\n${html}`);
assert.ok(!html.includes("original"), `docA's baseline text must never leak into docB's preview:\n${html}`);
assert.ok(html.includes("bravo untouched"), "docB renders its own content unannotated");
});
// Finding 1, failure mode (b): `lastCoeditedUri` is never re-validated
// against the registry, so a doc's OWN preview stayed annotated after
// `stopCoediting` — an INV-10 violation for the doc whose gate was just
// closed, not just for a different doc.
test("INV-10: stopCoediting immediately clears a doc's own preview annotations", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "gamma original\n" });
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const start = doc.getText().indexOf("original");
const ok = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + "original".length,
newText: "REPLACED",
model: "sonnet",
sessionId: "e2e-inv10-stop",
turnId: "turn-e2e-inv10-stop",
});
assert.strictEqual(ok, true, "seam edit applies");
await settle();
const md = new MarkdownIt();
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
const before = md.render(doc.getText(), { currentDocument: doc.uri });
assert.ok(before.includes("cw-ins-claude"), `sanity: annotated while coediting, got:\n${before}`);
await vscode.commands.executeCommand("cowriting.stopCoediting");
await settle();
const after = md.render(doc.getText(), { currentDocument: doc.uri });
assert.ok(!after.includes("cw-ins-claude"), `expected a clean render after stopCoediting, got:\n${after}`);
});
});
+117
View File
@@ -0,0 +1,117 @@
import * as assert from "assert";
import * as vscode from "vscode";
import { activateApi, settleUntil } from "./helpers";
// Task 6 (D19/D10/D8, PUC-8): the comments-first ask + comment→reply→offer→
// proposal loop, with a stubbed reply turn (no real @cline/sdk call). Runs on
// an untitled buffer (F8 routes it to the global sidecar) so this suite has no
// workspace-folder dependency.
suite("comment → reply → offer → proposal (PUC-8, D10/D19)", () => {
test("reply on a coedited doc summons a machine reply + offer; accept lands pending proposals", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({
language: "markdown",
content: "# T\n\nThe quick brown fox jumps over the lazy dog paragraph.\n",
});
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
api.threadController.setTurnRunnerForTest(async () => ({
replacement: "I would tighten this sentence.",
model: "stub",
sessionId: "s1",
}));
api.editFlow.setEditTurnForTest(async () => ({
replacement: "The quick fox jumps the lazy dog.",
model: "stub",
sessionId: "s1",
}));
ed.selection = new vscode.Selection(2, 0, 2, 20);
const threadId = (await api.threadController.createThreadOnSelection("tighten this"))!;
// reply-loop fires on the human message; wait for the machine message to persist
await settleUntil(() => {
const t = api.sidecarRouter.load(api.proposalController.keyFor(doc))?.threads.find((x) => x.id === threadId);
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
}, 10000);
// Finding 2 (final whole-branch review): the machine reply flips the
// thread into an "offer" — this used to CLOBBER the "open" status token
// instead of folding in alongside it, so Resolve/Reopen disappeared from
// the menu the moment any thread got a reply. Assert both tokens survive
// together, directly via the rendered vsThread's contextValue (the exact
// string package.json's `comments/commentThread/title` `when`-clauses key
// on) — no DOM/UI query needed.
const docKey = api.proposalController.keyFor(doc);
const afterReply = api.threadController.getRendered(docKey).find((t) => t.id === threadId);
assert.ok(afterReply, "thread is rendered after the reply lands");
assert.match(
afterReply!.contextValue,
/\bopen\b/,
`expected the "open" status token to survive the offer transition, got "${afterReply!.contextValue}"`,
);
assert.match(
afterReply!.contextValue,
/\boffer\b/,
`expected the "offer" token once the machine reply lands, got "${afterReply!.contextValue}"`,
);
const ids = await api.threadController.makeThreadEdit(threadId, api.proposalController.keyFor(doc));
assert.ok(ids.length >= 1);
assert.ok(api.proposalController.listProposals(doc).length >= 1); // pending, INV-5
// Spending the offer flips it to "offerdone" — status token still intact,
// and the plain "offer" token must not still match (it's a DIFFERENT word,
// not just a substring: /\boffer\b/ must not match "offerdone").
const afterMakeEdit = api.threadController.getRendered(docKey).find((t) => t.id === threadId);
assert.match(afterMakeEdit!.contextValue, /\bopen\b/, "status token still intact after spending the offer");
assert.match(afterMakeEdit!.contextValue, /\bofferdone\b/, "offer token flips to offerdone once spent");
assert.doesNotMatch(afterMakeEdit!.contextValue, /\boffer\b(?!done)/, "the bare offer token is gone once spent");
});
test("a comment on a NON-coedited doc summons nothing (INV-10)", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "plain\n" });
await vscode.window.showTextDocument(doc);
const id = await api.threadController.createThreadOnSelection("hello");
assert.strictEqual(id, undefined); // gate refuses thread creation entirely
});
// Finding 1 (code review, session native-surfaces-exec): `cowriting.edit` with NO
// selection used to route through `cowriting.editDocument` → the removed
// `askEditInstruction` webview prompt — a rejecting stub `void`'d before any
// try/catch, i.e. a completely silent dead end (no toast, no thread). It must now
// reach ThreadController.askClaude()'s top-anchored whole-document path (D19) —
// the same comments-first ask the selection route already used. There is no API to
// drive VS Code's native comment-widget submission from a host E2E test (the actual
// comment→reply→offer→proposal round trip is exercised above via the
// createThreadOnSelection seam), so this asserts the routing itself: the gesture
// reaches ThreadController.askClaude (never the old broken stub) and does not throw.
test("cowriting.edit with no selection on a coedited doc reaches the top-anchored ask path, not a silent dead end", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({
language: "markdown",
content: "# T\n\nA document-level ask with no selection.\n",
});
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
// Defensive stubs, mirroring the suite's other test — no real turn should fire
// from this gesture alone (no comment text is ever submitted), but guard against
// a real @cline/sdk call if the routing regresses.
api.threadController.setTurnRunnerForTest(async () => ({ replacement: "stub", model: "stub", sessionId: "s1" }));
api.editFlow.setEditTurnForTest(async () => ({ replacement: "stub", model: "stub", sessionId: "s1" }));
ed.selection = new vscode.Selection(0, 0, 0, 0); // no selection
let askClaudeCalls = 0;
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
api.threadController.askClaude = async () => {
askClaudeCalls++;
return origAskClaude();
};
try {
await vscode.commands.executeCommand("cowriting.edit");
} finally {
api.threadController.askClaude = origAskClaude;
}
assert.strictEqual(
askClaudeCalls,
1,
"cowriting.edit (no selection) reached ThreadController.askClaude — not the removed editDocument prompt stub",
);
});
});
+9
View File
@@ -17,6 +17,8 @@ async function open(docRel: string): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, docRel));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
// INV-10: thread creation/rendering is gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc;
}
async function getApi(): Promise<CowritingApi> {
@@ -55,6 +57,13 @@ suite("F5 cross-rung round-trip (host E2E)", () => {
const DOC = "docs/crossrung.md";
const doc = await open(DOC);
const api = await getApi();
// Task 6 (D10): createThreadOnSelection also fires the respond-in-thread
// turn now — stub it off so it can't race the forge stand-in's reply
// below (this test asserts an EXACT 2-message sequence: editor note +
// forge reply).
api.threadController.setTurnRunnerForTest(async () => {
throw new Error("crossrung suite: reply-loop turn stubbed off");
});
const target = "portable record";
const start = doc.getText().indexOf(target);
const editor = vscode.window.activeTextEditor!;
+43 -23
View File
@@ -15,6 +15,16 @@ async function openDoc(): Promise<vscode.TextDocument> {
await vscode.window.showTextDocument(doc);
return doc;
}
/** Open the doc AND enter coediting (INV-10) — baselines are established only
* on entry (Task 2). Idempotent across tests: re-entering an already-coedited
* doc is a no-op, so the once-captured baseline survives (order-dependent
* suite, same as before). */
async function openAndCoedit(): Promise<vscode.TextDocument> {
const doc = await openDoc();
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return doc;
}
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
@@ -29,22 +39,29 @@ const settle = () => new Promise((r) => setTimeout(r, 300));
// later tests consume earlier state. Owns docs/diffview.md exclusively. The
// baseline works on ANY file (#19), so the last two tests use an out-of-workspace
// file and an untitled buffer.
//
// Native-surfaces migration (Task 2, spec §6.4/INV-7): a baseline is now
// established only once a document ENTERS coediting (not merely opened), and
// the fixture workspace is NOT a git repo, so every doc here resolves to
// SNAPSHOT mode. The shipped machine-landing baseline advance (#48/INV-18) is
// retired — an accepted proposal now stays a visible change-since-baseline
// until "Mark Changes as Reviewed" (D21).
suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, no LLM)", () => {
const TARGET = "A target sentence Claude will rewrite via the seam.";
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
test("opening a tracked doc captures an `opened` baseline equal to the buffer (INV-18)", async () => {
const doc = await openDoc();
await getApi();
test("entering coediting on a snapshot-mode doc captures an `entered` baseline equal to the buffer (INV-7)", async () => {
const doc = await openAndCoedit();
const api = await getApi();
assert.strictEqual(api.diffViewController.modeOf(docUri()), "snapshot", "no git repo → snapshot mode");
const baseline = api.diffViewController.getBaseline(docUri());
assert.ok(baseline, "baseline captured on first sight");
assert.strictEqual(baseline!.reason, "opened");
assert.strictEqual(baseline!.text, doc.getText(), "baseline = open-time buffer");
assert.ok(baseline, "baseline captured on coediting entry");
assert.strictEqual(baseline!.reason, "entered");
assert.strictEqual(baseline!.text, doc.getText(), "baseline = entry-time buffer");
});
test("typing leaves the baseline unchanged while the buffer diverges", async () => {
const doc = await openDoc();
const doc = await openAndCoedit();
const api = await getApi();
const before = api.diffViewController.getBaseline(docUri())!.text;
const edit = new vscode.WorkspaceEdit();
@@ -55,9 +72,10 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
assert.notStrictEqual(doc.getText(), before, "buffer diverged");
});
test("accepting a proposal advances the baseline past the landed text (PUC-2, INV-18)", async () => {
const doc = await openDoc();
test("accepting a proposal does NOT advance the baseline the landed text stays a change (INV-7/D21, #48 retired)", async () => {
const doc = await openAndCoedit();
const api = await getApi();
const before = api.diffViewController.getBaseline(docUri())!;
const start = doc.getText().indexOf(TARGET);
assert.ok(start >= 0, "fixture contains the target");
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
@@ -73,14 +91,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle();
const baseline = api.diffViewController.getBaseline(docUri())!;
assert.strictEqual(baseline.reason, "machine-landing", "baseline advanced on the landing");
assert.ok(baseline.text.includes(REPLACEMENT), "landed text is in the baseline (won't show as a change)");
assert.ok(!baseline.text.includes(TARGET), "old target gone from the baseline too");
assert.strictEqual(baseline.text, doc.getText(), "baseline == buffer right after the landing");
assert.strictEqual(baseline.reason, before.reason, "baseline reason untouched by the landing");
assert.strictEqual(baseline.text, before.text, "baseline text untouched by the landing");
assert.ok(!baseline.text.includes(REPLACEMENT), "landed text is NOT folded into the baseline");
assert.notStrictEqual(baseline.text, doc.getText(), "baseline != buffer the landing reads as a change");
});
test("an operator edit after the landing makes baseline ≠ buffer (the operator delta)", async () => {
const doc = await openDoc();
test("an operator edit after the landing keeps baseline ≠ buffer (the operator delta)", async () => {
const doc = await openAndCoedit();
const api = await getApi();
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, new vscode.Position(0, 0), "POST-LANDING OPERATOR LINE\n");
@@ -89,14 +107,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
assert.notStrictEqual(
api.diffViewController.getBaseline(docUri())!.text,
doc.getText(),
"operator changes show against the advanced baseline",
"operator changes show against the untouched baseline",
);
});
test("pin resets the baseline to now: baseline == buffer, reason pinned", async () => {
const doc = await openDoc();
test("markReviewed resets the baseline to now: baseline == buffer, reason pinned", async () => {
const doc = await openAndCoedit();
const api = await getApi();
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
await vscode.commands.executeCommand("cowriting.markReviewed");
await settle();
const baseline = api.diffViewController.getBaseline(docUri())!;
assert.strictEqual(baseline.reason, "pinned");
@@ -104,14 +122,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
});
test("the baseline is persisted in GLOBAL storage, never the repo (INV-19)", async () => {
await openDoc();
await openAndCoedit();
const api = await getApi();
const p = api.diffViewController.baselineFilePath(docUri());
assert.ok(p, "storage-backed baseline path is available for a file: doc");
assert.ok(fs.existsSync(p!), `baseline file exists at ${p}`);
const onDisk = JSON.parse(fs.readFileSync(p!, "utf8"));
assert.strictEqual(onDisk.uri, docUri(), "baseline records the document URI");
assert.strictEqual(onDisk.reason, "pinned", "last epoch (pin) persisted");
assert.strictEqual(onDisk.reason, "pinned", "last epoch (markReviewed) persisted");
assert.strictEqual(onDisk.text, api.diffViewController.getBaseline(docUri())!.text, "on-disk == in-memory");
// INV-19: baseline lives under the extension's storage dir, not the repo.
assert.ok(!p!.includes(`${path.sep}.threads${path.sep}`), "baseline is NOT in the sidecar tree");
@@ -127,11 +145,12 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
const outsideUri = vscode.Uri.file(outsidePath);
const doc = await vscode.workspace.openTextDocument(outsideUri);
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
// Captured on open even though it is NOT under the workspace folder.
// Captured on coediting entry even though it is NOT under the workspace folder.
const baseline = api.diffViewController.getBaseline(outsideUri.toString());
assert.ok(baseline, "baseline captured for an out-of-folder file");
assert.strictEqual(baseline!.reason, "opened");
assert.strictEqual(baseline!.reason, "entered");
const fp = api.diffViewController.baselineFilePath(outsideUri.toString());
assert.ok(fp && fs.existsSync(fp), "outside-file baseline persisted in global storage");
assert.ok(!fp!.startsWith(WS + path.sep), "not under the workspace folder");
@@ -143,6 +162,7 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
const api = await getApi();
const untitled = await vscode.workspace.openTextDocument({ content: "scratch line\n", language: "markdown" });
await vscode.window.showTextDocument(untitled);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const key = untitled.uri.toString();
assert.strictEqual(untitled.uri.scheme, "untitled", "it really is an untitled buffer");
-302
View File
@@ -1,302 +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. */
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
const abs = path.join(WS, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body, "utf8");
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settle();
return { doc, key: uri.toString() };
}
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 the baseline advances (PUC-4)", 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");
// The baseline advanced on the landing (INV-18): the landed text is not marked.
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 renders unmarked (baseline advanced)");
});
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: pinDiffBaseline stays a real command.
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline (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);
await settle();
return { doc, key: uri.toString() };
}
+33 -105
View File
@@ -10,11 +10,13 @@ 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;
}
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */
/** 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 });
@@ -22,47 +24,28 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
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() };
}
// 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`.
test("pinDiffBaseline is reachable from the command palette (when: editorLangId == markdown)", async () => {
// Renamed cowriting.pinDiffBaseline → cowriting.markReviewed (Task 2, D14).
test("markReviewed is reachable from the command palette (when: editorLangId == markdown)", async () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.pinDiffBaseline",
(m) => m.command === "cowriting.markReviewed",
);
assert.ok(entry, "pinDiffBaseline has a commandPalette entry");
assert.ok(entry, "markReviewed has a commandPalette entry");
assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
});
@@ -76,18 +59,16 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
"# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n",
);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const editFlow = api.editFlow;
// Stub the host edit turn (no LLM in CI): rewrite two distinct words.
ctl.setEditTurnForTest(async () => ({
editFlow.setEditTurnForTest(async () => ({
replacement: "# F11 doc\n\nThe quick RED fox jumps over the lazy CAT.\n",
model: "sonnet",
sessionId: "e2e-f11-doc",
}));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT");
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT");
await settle();
assert.strictEqual(ids.length, 1, "two changed words in one block → ONE block proposal (INV-39)");
@@ -99,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;
});
@@ -110,19 +92,17 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
const body = "# F11 sel\n\nThe target paragraph Claude will rewrite.\n\nAnother untouched paragraph.\n";
const { doc, key } = await freshDoc("docs/f11sel.md", body);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const editFlow = api.editFlow;
const target = "The target paragraph Claude will rewrite.";
const start = doc.getText().indexOf(target);
const end = start + target.length;
ctl.setEditTurnForTest(async (_instruction, text) => {
editFlow.setEditTurnForTest(async (_instruction, text) => {
assert.strictEqual(text, target, "the turn receives exactly the selected source range");
return { replacement: "The REWRITTEN paragraph from Claude.", model: "sonnet", sessionId: "e2e-f11-sel" };
});
const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph");
const ids = await editFlow.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph");
await settle();
assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal");
const views = api.proposalController.listProposals(doc);
@@ -140,13 +120,11 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
test("runEditAndPropose(range) where Claude returns the selection unchanged → no proposal", async () => {
const { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const editFlow = api.editFlow;
const target = "Leave me exactly as I am.";
const start = doc.getText().indexOf(target);
ctl.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change");
editFlow.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" }));
const ids = await editFlow.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change");
assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing");
});
@@ -158,12 +136,10 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
const rewrite = "# F11 accept\n\nThe brown fox QUIETLY sleeps today.\n";
const { doc } = await freshDoc("docs/f11accept.md", original);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const editFlow = api.editFlow;
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "expand the sentence");
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "expand the sentence");
await settle();
assert.ok(ids.length >= 1, "the rewrite produced at least one proposal");
@@ -194,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();
}
});
});
+20 -21
View File
@@ -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;
}
@@ -21,6 +21,9 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
// INV-10: proposal rendering (F12 optimistic-apply, driven by
// onDidChangeProposals) is gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return { doc, key: uri.toString() };
}
@@ -33,19 +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;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const editFlow = api.editFlow;
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns");
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();
@@ -60,12 +62,10 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
const rewrite = "# Mix\n\nKeep ALPHA here.\n\nKeep GAMMA here.\n";
const { doc } = await freshDoc("docs/f12-orphan.md", original);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const editFlow = api.editFlow;
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase");
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" }));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase");
await settle();
assert.strictEqual(ids.length, 2, "two pending proposals");
@@ -92,20 +92,19 @@ 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 ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
ctl.setEditTurnForTest(async () => ({
const editFlow = api.editFlow;
editFlow.setEditTurnForTest(async () => ({
replacement: "# One\n\nThe ONLY paragraph here.\n",
model: "sonnet",
sessionId: "e2e-f12-one",
}));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase only");
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase only");
await settle();
assert.strictEqual(ids.length, 1, "one pending proposal");
const { applied, skipped } = await api.proposalController.acceptAllProposals(doc);
+30 -26
View File
@@ -12,10 +12,12 @@ 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;
}
/** 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 });
@@ -23,6 +25,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
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() };
}
@@ -31,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(
@@ -103,10 +104,10 @@ suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => {
test("rejectAll reverts every pending proposal", async () => {
const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
const editFlow = api.editFlow;
const p = api.proposalController;
ctl.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
await settle();
for (const id of ids) await p.optimisticApply(doc, id);
await settle();
@@ -123,9 +124,9 @@ suite("F12 inline diff — INV-50 listProposals.replaced", () => {
test("listProposals reports the original as `replaced` after optimistic apply", async () => {
const { doc } = await freshDoc("docs/f12-replaced.md", "# R\n\nbrown here.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" }));
await ctl.runEditAndPropose(doc, { kind: "document" }, "x");
const editFlow = api.editFlow;
editFlow.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" }));
await editFlow.runEditAndPropose(doc, { kind: "document" }, "x");
await settle(); await settle();
assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here.");
});
@@ -135,9 +136,9 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
test("proposing optimistically applies into the editor and the buffer is the accepted result", async () => {
const { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" }));
await ctl.runEditAndPropose(doc, { kind: "document" }, "recolor the fox");
const editFlow = api.editFlow;
editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" }));
await editFlow.runEditAndPropose(doc, { kind: "document" }, "recolor the fox");
await settle(); await settle();
assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer");
const v = api.proposalController.listProposals(doc)[0];
@@ -147,9 +148,9 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
test("editing the inserted text then finalizing keeps the human edit", async () => {
const { doc } = await freshDoc("docs/f12-edit-keep.md", "# E\n\nalpha word here.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
const editFlow = api.editFlow;
editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" }));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle();
// human tweaks the inserted text
const at = doc.getText().indexOf("ALPHA");
@@ -168,22 +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;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
ctl.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
const p = api.proposalController;
const editFlow = api.editFlow;
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);
+57 -30
View File
@@ -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;
}
@@ -20,6 +20,12 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
fs.writeFileSync(abs, body, "utf8");
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
// INV-10: cowriting.editDocument now warns instead of editing a non-entered
// doc (Task 4) — enter it here (briefly making it active) so the tests below
// exercise the routing/targeting behavior, not the gate. The caller
// re-establishes whichever doc it wants active afterward.
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
return { doc, key: uri.toString() };
}
@@ -66,9 +72,17 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
// PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
// not whatever editor happens to be active (mirrors #41's clicked-doc resolution).
// Finding 1 fix (native-surfaces code review): editDocument no longer prompts via
// the removed askEditInstruction webview stub (that path threw a silent, `void`'d,
// unhandled rejection — no toast, no thread, no proposal). It now resolves/focuses
// its target document and hands off to ThreadController.askClaude() (D19), same as
// editSelection. The turn→proposal cut itself (EditFlow.runEditAndPropose against a
// {kind:"document"} target) is exercised directly, without the command, in
// f11Toolbar.test.ts / f12Accept.test.ts / f12Review.test.ts — this test's job is
// the URI-targeting/focus behavior, plus confirming the routing actually reaches
// askClaude (proving Finding 1's dead end is gone).
test("editDocument(uri) targets the clicked tab's document, not the active editor", async () => {
const api = await getApi();
const ctl = api.trackChangesPreviewController;
// Doc A is the active editor; Doc B is the "clicked tab" we pass by URI.
const a = await freshDoc("docs/f12-active.md", "# Active\n\nThe active editor paragraph.\n");
@@ -76,55 +90,68 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.window.showTextDocument(a.doc);
await settle();
// Stub the document instruction prompt (the webview can't run in CI) + the LLM turn.
const origPrompt = ctl.askEditInstruction;
ctl.askEditInstruction = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
model: "sonnet",
sessionId: "e2e-f12-tab",
}));
let askClaudeCalls = 0;
// Capture the active-editor doc INSIDE the spy, before delegating to the real
// askClaude — askClaude's own `workbench.action.addComment` side effect moves
// focus to the ephemeral comment-input widget (a `comment://` URI), which would
// make a post-hoc `activeTextEditor` check meaningless. What we're proving here
// is editDocument's OWN resolve-and-focus step ran against the right document
// before handing off.
let focusedDocAtHandoff: string | undefined;
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
api.threadController.askClaude = async () => {
askClaudeCalls++;
focusedDocAtHandoff = vscode.window.activeTextEditor?.document.uri.toString();
return origAskClaude();
};
try {
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
await settle();
} finally {
ctl.askEditInstruction = origPrompt;
api.threadController.askClaude = origAskClaude;
}
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
assert.ok(api.proposalController.listProposals(b.doc).length >= 1, "tab doc B received the document-edit proposal(s)");
assert.strictEqual(
api.proposalController.listProposals(a.doc).length,
0,
"active doc A was NOT edited — editDocument honored the tab URI",
askClaudeCalls,
1,
"editDocument(uri) reached ThreadController.askClaude — not the removed prompt stub",
);
assert.strictEqual(
focusedDocAtHandoff,
b.doc.uri.toString(),
"editDocument(uri) focused the TAB doc B, not the previously-active doc A, before handing off to askClaude",
);
// F12 (INV-48): EditorProposalController optimistically applies proposals into the
// buffer, so the proposed text is now in the tab doc B. Confirm one of the proposal
// texts is present (the tab doc was edited, not A).
assert.ok(b.doc.getText().includes("REWRITTEN"), "F12 optimistic-apply: proposed text in tab doc B");
});
// No URI arg (palette / keybinding) → fall back to the active editor.
test("editDocument() with no arg targets the active editor", async () => {
const api = await getApi();
const ctl = api.trackChangesPreviewController;
const a = await freshDoc("docs/f12-noarg.md", "# No arg\n\nThe active doc paragraph here.\n");
await vscode.window.showTextDocument(a.doc);
await settle();
const origPrompt = ctl.askEditInstruction;
ctl.askEditInstruction = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
model: "sonnet",
sessionId: "e2e-f12-noarg",
}));
let askClaudeCalls = 0;
// See the sibling test above for why this is captured inside the spy rather
// than after askClaude() returns.
let focusedDocAtHandoff: string | undefined;
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
api.threadController.askClaude = async () => {
askClaudeCalls++;
focusedDocAtHandoff = vscode.window.activeTextEditor?.document.uri.toString();
return origAskClaude();
};
try {
await vscode.commands.executeCommand("cowriting.editDocument");
await settle();
} finally {
ctl.askEditInstruction = origPrompt;
api.threadController.askClaude = origAskClaude;
}
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
assert.strictEqual(askClaudeCalls, 1, "editDocument() with no arg reached ThreadController.askClaude");
assert.strictEqual(
focusedDocAtHandoff,
a.doc.uri.toString(),
"editDocument() with no arg kept the active editor's doc active before handing off to askClaude",
);
});
});
+68 -13
View File
@@ -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;
}
@@ -20,6 +20,9 @@ async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument>
fs.writeFileSync(abs, body, "utf8");
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
await vscode.window.showTextDocument(doc);
// INV-10: attribution tracking (accept's per-word attribution, INV-40) is
// gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return doc;
}
@@ -36,13 +39,13 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
"# Doc\n\nFirst paragraph alpha.\n\nSecond paragraph beta.\n\nThird paragraph gamma.\n",
);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({
const editFlow = api.editFlow;
editFlow.setEditTurnForTest(async () => ({
replacement: "# Doc\n\nFirst paragraph ALPHA.\n\nSecond paragraph beta.\n\nThird paragraph GAMMA.\n",
model: "sonnet",
sessionId: "e2e-f12-multi",
}));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns");
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns");
await settle();
assert.strictEqual(ids.length, 2, "two changed blocks → two proposals; the unchanged middle block → none");
@@ -59,13 +62,13 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
"# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n",
);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({
const editFlow = api.editFlow;
editFlow.setEditTurnForTest(async () => ({
replacement: "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n",
model: "sonnet",
sessionId: "e2e-f12-fence",
}));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "bump a to 10");
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "bump a to 10");
await settle();
assert.strictEqual(ids.length, 1, "a changed fence is one atomic proposal");
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0])!;
@@ -77,15 +80,15 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
test("accepting a block proposal attributes only the changed words to Claude (INV-40)", async () => {
const doc = await freshDoc("docs/f12-attr.md", "# T\n\nThe quick brown fox jumps lazily.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
const editFlow = api.editFlow;
const key = api.proposalController.keyFor(doc);
ctl.setEditTurnForTest(async () => ({
editFlow.setEditTurnForTest(async () => ({
replacement: "# T\n\nThe quick RED fox jumps SLOWLY.\n",
model: "sonnet",
sessionId: "e2e-f12-attr",
}));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "change two words");
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "change two words");
await settle();
assert.strictEqual(ids.length, 1, "one changed paragraph → one block proposal");
@@ -115,11 +118,11 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
const rewrite = "# Ins\n\nAlpha block.\n\nBrand new middle block.\n\nBeta block.\n";
const doc = await freshDoc("docs/f12-insert.md", original);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
const editFlow = api.editFlow;
const key = api.proposalController.keyFor(doc);
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph");
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" }));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph");
await settle();
assert.ok(ids.length >= 1, "the insertion produced at least one proposal");
@@ -132,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)");
});
});
+164
View File
@@ -0,0 +1,164 @@
import * as assert from "node:assert";
import * as vscode from "vscode";
import { activateApi, settle, settleUntil } from "./helpers";
/**
* Task 9 (spec §6.8 named E2E, §7.1 rung 2 stub-turned): the full
* native-surfaces inner loop end to end, in ONE doc:
* PUC-7 (enter coediting, snapshot baseline)
* PUC-8 (comment reply offer pending proposal, D10/D19)
* PUC-2 (buffer review: tweak under typing / re-anchor, keep, reject)
* PUC-1 (pin the baseline clean).
* Both turns are stubbed (no real @cline/sdk call) the real-SDK rung 3 is
* `scripts/smoke-native-loop.mjs`, operator-run against live credentials.
*/
suite("full native loop — PUC-7 → PUC-8 → PUC-2 → PUC-1 (§6.8, §7.1 rung 2)", () => {
test("enter, ask twice, offer, tweak-under-typing, keep one, reject one, pin — clean review", async () => {
const api = await activateApi();
const ORIG_KEEP = "The quick brown fox jumps over the lazy dog paragraph.";
const ORIG_REJECT = "A second paragraph that should stay exactly as written.";
const doc = await vscode.workspace.openTextDocument({
language: "markdown",
content: `# Full loop\n\n${ORIG_KEEP}\n\n${ORIG_REJECT}\n`,
});
const ed = await vscode.window.showTextDocument(doc);
// PUC-7: enter coediting. An untitled doc has no git HEAD, so it lands in
// snapshot mode (INV-7/D13/D14 — baselineRouter.test.ts covers the fork).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
assert.strictEqual(
api.diffViewController.modeOf(doc.uri.toString()),
"snapshot",
"untitled doc enters coediting in snapshot mode",
);
const docKey = api.proposalController.keyFor(doc);
const KEEP_REPLACEMENT = "The quick fox jumps the lazy dog.";
const REJECT_REPLACEMENT = "A rewritten second paragraph nobody asked to keep.";
// ---- PUC-8, first ask: the "keep" paragraph -------------------------------------
api.threadController.setTurnRunnerForTest(async () => ({
replacement: "I'd tighten this sentence.",
model: "stub",
sessionId: "full-loop-1",
}));
api.editFlow.setEditTurnForTest(async () => ({
replacement: KEEP_REPLACEMENT,
model: "stub",
sessionId: "full-loop-1",
}));
const keepStart = doc.getText().indexOf(ORIG_KEEP);
ed.selection = new vscode.Selection(doc.positionAt(keepStart), doc.positionAt(keepStart + ORIG_KEEP.length));
const keepThreadId = (await api.threadController.createThreadOnSelection("tighten this"))!;
assert.ok(keepThreadId, "thread created on a coedited doc (INV-10 gate open)");
// The reply loop fires on the human message; wait for the machine reply to
// land and flip the thread to "offer" (D10/PUC-8).
await settleUntil(() => {
const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === keepThreadId);
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
}, 10000);
const keepIds = await api.threadController.makeThreadEdit(keepThreadId, docKey);
assert.strictEqual(keepIds.length, 1, "one pending proposal from the ranged ask");
const keepId = keepIds[0];
// ---- PUC-8, second ask: the "reject" paragraph ----------------------------------
api.editFlow.setEditTurnForTest(async () => ({
replacement: REJECT_REPLACEMENT,
model: "stub",
sessionId: "full-loop-2",
}));
const rejectStart = doc.getText().indexOf(ORIG_REJECT);
ed.selection = new vscode.Selection(doc.positionAt(rejectStart), doc.positionAt(rejectStart + ORIG_REJECT.length));
const rejectThreadId = (await api.threadController.createThreadOnSelection("rewrite this"))!;
await settleUntil(() => {
const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === rejectThreadId);
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
}, 10000);
const rejectIds = await api.threadController.makeThreadEdit(rejectThreadId, docKey);
assert.strictEqual(rejectIds.length, 1, "one pending proposal from the second ranged ask");
const rejectId = rejectIds[0];
// ---- decorated pending proposals (F12 optimistic-apply) --------------------------
await settleUntil(
() => api.proposalController.isApplied(docKey, keepId) && api.proposalController.isApplied(docKey, rejectId),
5000,
);
let views = api.proposalController.listProposals(doc);
assert.strictEqual(views.length, 2, "two pending proposals decorated");
for (const id of [keepId, rejectId]) {
const v = views.find((x) => x.id === id)!;
assert.notStrictEqual(v.anchorStart, null, "each proposal resolves (pending, not orphaned)");
assert.ok(api.proposalController.isApplied(docKey, id), "optimistically applied into the buffer");
}
assert.ok(doc.getText().includes(KEEP_REPLACEMENT), "claude's first proposed rewrite is live in the buffer");
assert.ok(doc.getText().includes(REJECT_REPLACEMENT), "claude's second proposed rewrite is live in the buffer");
// ---- PUC-2, tweak: type INSIDE the still-pending "keep" range --------------------
// Verified against src/anchorer.ts: `resolve()` is an EXACT-substring search
// (INV-1 — no fuzzy tolerance), so an interior edit to a proposal's own
// fingerprinted text unconditionally breaks that fingerprint — this is INV-11,
// already covered by proposals.test.ts's "editing the target text makes the
// proposal stale" case. F12's finalizeInPlace ("Keep") is deliberately built to
// bypass that staleness check (see its own doc comment: "Direct finalizeInPlace
// calls ... where the user may have edited inside the applied span bypass this
// check intentionally") — the tweaked text is ALREADY the accepted result
// sitting in the buffer, so no re-resolve is needed to keep it. That is the
// real "re-anchor" story for a pending-range tweak: the proposal survives as a
// live, addressable id (not silently dropped) and Keep still lands it, even
// though its raw anchor is (correctly) stale.
const at = doc.getText().indexOf(KEEP_REPLACEMENT);
const tweak = new vscode.WorkspaceEdit();
tweak.replace(doc.uri, new vscode.Range(doc.positionAt(at + 10), doc.positionAt(at + 13)), "FOX"); // "fox" -> "FOX"
assert.ok(await vscode.workspace.applyEdit(tweak), "human tweak applied inside the pending range");
await settle();
assert.ok(doc.getText().includes("The quick FOX jumps the lazy dog."), "human tweak landed in the buffer");
views = api.proposalController.listProposals(doc);
const keepView = views.find((v) => v.id === keepId);
assert.ok(keepView, "the tweaked proposal is still a live, addressable pending proposal (not silently dropped)");
assert.strictEqual(
keepView!.anchorStart,
null,
"INV-11: the interior tweak breaks the exact-fingerprint anchor (flagged stale, never guessed)",
);
// ---- PUC-2, keep: finalize the tweaked proposal in place -------------------------
assert.ok(await api.proposalController.finalizeInPlace(docKey, keepId), "keep finalizes in place");
await settle();
assert.strictEqual(
api.proposalController.listProposals(doc).find((v) => v.id === keepId),
undefined,
"kept proposal cleared",
);
// Attribution split (F3xF12): Claude's words AND the human tweak are both
// attributed, char-honest (spansFor).
const spans = api.attributionController.spansFor(doc);
const claudeSpan = spans.find(
(s) => s.author === "claude" && doc.getText().slice(s.start, s.end).includes("jumps the lazy dog"),
);
const humanSpan = spans.find((s) => s.author === "human" && doc.getText().slice(s.start, s.end) === "FOX");
assert.ok(claudeSpan, "claude's words are attributed");
assert.ok(humanSpan, "the human tweak is attributed separately (char-honest split)");
// ---- PUC-2, reject: revert the untouched second proposal in place ----------------
assert.ok(await api.proposalController.revertInPlace(docKey, rejectId), "reject reverts in place");
await settle();
assert.strictEqual(
api.proposalController.listProposals(doc).find((v) => v.id === rejectId),
undefined,
"rejected proposal cleared",
);
// INV-5: the rejected range returns EXACTLY to its pre-proposal text.
assert.ok(doc.getText().includes(ORIG_REJECT), "rejected range restored EXACTLY to its pre-proposal text (INV-5)");
assert.ok(!doc.getText().includes(REJECT_REPLACEMENT), "claude's rejected rewrite is gone from the buffer");
// ---- PUC-1: pin the baseline (Mark Changes as Reviewed) --------------------------
await vscode.commands.executeCommand("cowriting.markReviewed");
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 0, 5000);
assert.strictEqual(
api.diffViewController.getBaseline(doc.uri.toString())?.reason,
"pinned",
"baseline re-pinned to the clean, fully-reviewed buffer",
);
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Shared host-E2E helpers (native-surfaces migration, Task 2). NEW suites use
* these; existing suites keep their own local copies (out of scope to
* refactor them onto this shared file see the Task 2 brief).
*/
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> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.diffViewController, "extension exports diffViewController");
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));
}
/** Poll `predicate` every 100ms until it is true, or fail after `timeoutMs`. */
export async function settleUntil(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
assert.fail(`settleUntil: predicate did not become true within ${timeoutMs}ms`);
}
await new Promise((r) => setTimeout(r, 100));
}
}
+10 -11
View File
@@ -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;
}
@@ -20,6 +20,9 @@ async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument>
fs.writeFileSync(abs, body, "utf8");
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
await vscode.window.showTextDocument(doc);
// INV-10: proposal rendering (F12 optimistic-apply, driven by
// onDidChangeProposals) is gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return doc;
}
@@ -33,19 +36,17 @@ suite("#60 live turn progress (additive + cancel)", () => {
test("a stub that emits progress still produces the same proposals (INV-44)", async () => {
const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const editFlow = api.editFlow;
// The stub honors opts.onProgress (emitting synthetic snapshots) but returns
// the same rewrite — proposals must be unaffected by progress events.
ctl.setEditTurnForTest(async (_i, _text, opts) => {
editFlow.setEditTurnForTest(async (_i, _text, opts) => {
opts?.onProgress?.({ phase: "writing", chars: 5 });
opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." });
return { replacement: "# Title\n\nNew paragraph.\n", model: "sonnet", sessionId: "e2e-live60" };
});
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph");
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph");
await settle();
assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events");
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]);
@@ -59,14 +60,12 @@ suite("#60 live turn progress (additive + cancel)", () => {
test("an aborted turn proposes nothing (INV-47)", async () => {
const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const editFlow = api.editFlow;
// 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
// rewrite and create a proposal, failing this test. That proves propagation.
ctl.setEditTurnForTest(async (_i, _text, opts) => {
editFlow.setEditTurnForTest(async (_i, _text, opts) => {
if (opts?.signal?.aborted) throw new Error("claude-code turn aborted");
return { replacement: "# Title\n\nSHOULD NOT BE PROPOSED.\n", model: "sonnet", sessionId: "e2e-live60-nope" };
});
@@ -75,7 +74,7 @@ suite("#60 live turn progress (additive + cancel)", () => {
ac.abort();
let ids: string[] = [];
try {
ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
} catch {
ids = [];
}
+10
View File
@@ -37,8 +37,16 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)",
const uri = vscode.Uri.file(outsidePath);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
// INV-10: attribution tracking + thread creation are gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const api = await getApi();
// Task 6 (D10): createThreadOnSelection (used later in this test) also
// fires the respond-in-thread turn now — stub it off (this "no LLM" suite
// asserts the programmatic propose/accept seam, not the reply loop).
api.threadController.setTurnRunnerForTest(async () => {
throw new Error("out-of-workspace suite: reply-loop turn stubbed off");
});
const key = uri.toString();
const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1");
@@ -80,6 +88,8 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)",
const TARGET = "The untitled draft sentence.";
const untitled = await vscode.workspace.openTextDocument({ content: `${TARGET}\n`, language: "markdown" });
await vscode.window.showTextDocument(untitled);
// INV-10: attribution tracking is gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const api = await getApi();
const key = untitled.uri.toString();
+79
View File
@@ -0,0 +1,79 @@
import * as assert from "node:assert";
import MarkdownIt from "markdown-it";
import * as vscode from "vscode";
import { cowritingMarkdownItPlugin } from "../../../src/previewAnnotations";
import { activateApi, settle } from "./helpers";
// Task 7 (D3/D21, PUC-3): the built-in preview's DOM isn't queryable from a
// host E2E test (§6.8), so this asserts the pure transform's INPUTS/OUTPUTS
// through the real `CowritingApi` seams instead — the production
// `previewAnnotationHost.inputsFor` (not a hand-rolled reimplementation) fed
// through the real `cowritingMarkdownItPlugin` + a fresh markdown-it instance
// (mirroring the unit suite's own `render()` helper), so this exercises the
// ACTUAL registry/diffView/attribution/proposals/config wiring end to end.
suite("PUC-3 annotate-toggle (built-in Markdown preview annotations)", () => {
test("a machine-attributed change renders cw-ins-claude; disabling the setting passes the source through unchanged", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({
language: "markdown",
content: "hello world\n",
});
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const start = doc.getText().indexOf("world");
const ok = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + "world".length,
newText: "brave new world",
model: "sonnet",
sessionId: "e2e-preview-annotations",
turnId: "turn-e2e-preview-annotations",
});
assert.strictEqual(ok, true, "seam edit applies");
await settle();
// Annotations ON (default): render the CURRENT buffer through the real
// markdown-it plugin + the production host — proves the full pipeline
// (core-rule swap → full-render sentinel walk) reaches a live doc.
const md = new MarkdownIt();
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
const html = md.render(doc.getText());
assert.ok(html.includes("cw-ins-claude"), `expected cw-ins-claude in:\n${html}`);
// Toggle OFF: the config-gated `enabled` flag reaches `annotateSource` and
// the transform becomes the identity (no core-rule swap, no marks).
const config = vscode.workspace.getConfiguration("cowriting");
await config.update("annotations", false, vscode.ConfigurationTarget.Global);
try {
const inputs = api.previewAnnotationHost.inputsFor(undefined);
assert.ok(inputs, "inputsFor still resolves a coedited doc while disabled");
assert.strictEqual(inputs!.enabled, false);
const md2 = new MarkdownIt();
cowritingMarkdownItPlugin(md2, api.previewAnnotationHost);
const plainMd = new MarkdownIt();
assert.strictEqual(md2.render(doc.getText()), plainMd.render(doc.getText()));
} finally {
await config.update("annotations", true, vscode.ConfigurationTarget.Global);
}
});
test("cowriting.toggleAnnotations flips the setting", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "toggle me\n" });
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const config = () => vscode.workspace.getConfiguration("cowriting");
const before = config().get<boolean>("annotations", true);
await vscode.commands.executeCommand("cowriting.toggleAnnotations");
assert.strictEqual(config().get<boolean>("annotations", true), !before);
// restore
await vscode.commands.executeCommand("cowriting.toggleAnnotations");
assert.strictEqual(config().get<boolean>("annotations", true), before);
void api; // keep the activated extension alive for the duration of the test
});
});
+4
View File
@@ -18,6 +18,10 @@ async function openDoc(): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
// INV-10: proposal rendering (and the F12 optimistic-apply it drives via
// onDidChangeProposals) is gated on coediting (Task 4) — idempotent across
// this order-dependent suite's repeated openDoc() calls.
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc;
}
async function getApi(): Promise<CowritingApi> {
+82 -37
View File
@@ -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)");
});
});
+11 -9
View File
@@ -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,10 +11,12 @@ 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;
}
/** 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 });
@@ -21,6 +24,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
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() };
}
@@ -32,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");
@@ -58,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");
});
});
+16
View File
@@ -18,6 +18,9 @@ async function openSample(): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
// INV-10: thread creation/rendering is gated on coediting (Task 4) — idempotent
// across this order-dependent suite's repeated openSample() calls.
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc;
}
@@ -38,6 +41,17 @@ async function getApi(): Promise<CowritingApi> {
return api;
}
// Task 6 (D10): every human comment on a coedited doc now also fires the
// respond-in-thread turn (createThreadOnSelection/reply). This suite predates
// that loop and asserts exact message sequences unrelated to it — stub the
// turn to reject so the loop runs harmlessly (no real @cline/sdk call, and no
// extra machine message lands in the sidecar this suite's assertions read).
function stubReplyLoop(api: CowritingApi): void {
api.threadController.setTurnRunnerForTest(async () => {
throw new Error("F2 suite: reply-loop turn stubbed off (not under test here)");
});
}
// Reaches the controller's internal docs map for reply/resolve handlers (which
// expect a live vscode.CommentThread). Spec §6.8 "drive the ThreadController and
// assert Comments-controller state" fallback.
@@ -64,6 +78,7 @@ suite("F2 region-anchored threads (host E2E)", () => {
test("create on selection persists a thread sidecar", async () => {
const doc = await openSample();
const api = await getApi();
stubReplyLoop(api);
const start = doc.getText().indexOf("quick brown fox");
const editor = vscode.window.activeTextEditor!;
editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length));
@@ -86,6 +101,7 @@ suite("F2 region-anchored threads (host E2E)", () => {
test("reply appends and resolve flips status, both persisted", async () => {
const api = await getApi();
stubReplyLoop(api);
const art0 = readSidecar();
const threadId = art0.threads[0].id;
-135
View File
@@ -1,135 +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();
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);
return doc;
}
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "extension exports trackChangesPreviewController");
return api;
}
const settle = () => new Promise((r) => setTimeout(r, 400));
const kinds = (api: CowritingApi) =>
(api.trackChangesPreviewController.getLastModel(docUri()) ?? []).map((o) => o.kind);
// Order-dependent (F2F6 pattern): later tests consume earlier state.
suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, no LLM)", () => {
const TARGET = "A target sentence Claude will rewrite via the seam.";
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
test("open on a markdown doc → panel open, opened baseline shows no changes (PUC-1)", async () => {
const doc = await openDoc();
const api = await getApi();
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), false, "no panel yet");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), true, "panel open");
const model = api.trackChangesPreviewController.getLastModel(docUri());
assert.ok(model && model.length > 0, "a model was computed");
assert.ok(
model!.every((o) => o.kind === "unchanged"),
"baseline == buffer at open → every block unchanged",
);
void doc;
});
test("typing produces a changed/added block (PUC-2)", async () => {
const doc = await openDoc();
const api = await getApi();
const edit = new vscode.WorkspaceEdit();
const end = doc.positionAt(doc.getText().length);
edit.insert(doc.uri, end, "\n\nA freshly typed paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle();
assert.ok(
kinds(api).some((k) => k === "added" || k === "changed"),
"an added/changed block appears after typing",
);
});
test("accepting a proposal advances the baseline; the landed block goes unchanged (PUC-3, INV-18)", 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();
// The baseline advanced to include REPLACEMENT, so the buffer == baseline for
// that block → it is NOT marked. Confirm the replacement is not flagged as a change.
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 renders unmarked (baseline advanced)");
});
test("pin 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.pinDiffBaseline");
await settle();
assert.ok(
kinds(api).every((k) => k === "unchanged"),
"after pin, 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.pinDiffBaseline");
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");
});
});
+7 -2
View File
@@ -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,10 +12,13 @@ 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;
}
/** Also enters coediting (INV-10/Task 2): the baseline is established only on
* entry, not merely on open needed for the preview's added/changed marks to
* reflect a real diff rather than a vacuous baseline==current fallback. */
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 });
@@ -22,6 +26,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
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() };
}
@@ -79,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");
});
+198
View File
@@ -0,0 +1,198 @@
import MarkdownIt from "markdown-it";
import { describe, expect, it } from "vitest";
import { cowritingMarkdownItPlugin } from "../src/previewAnnotations";
function render(src: string, inputs: any): string {
const md = new MarkdownIt();
cowritingMarkdownItPlugin(md, { inputsFor: () => inputs });
return md.render(src);
}
const base = { baselineReason: "entered", proposals: [], enabled: true };
describe("preview annotations (PUC-3/D21)", () => {
it("renders clean when disabled", () => {
const html = render("hello brave world\n", { ...base, enabled: false, baselineText: "hello world\n", spans: [] });
expect(html).not.toContain("cw-");
});
it("colors machine spans blue and human insertions green vs baseline", () => {
const html = render("hello brave new world\n", {
...base,
baselineText: "hello world\n",
spans: [{ start: 6, end: 12, author: "claude" }, { start: 12, end: 16, author: "human" }],
});
expect(html).toContain('class="cw-ins-claude"');
expect(html).toContain('class="cw-ins-human"');
});
it("strikes deletions vs baseline", () => {
const html = render("hello world\n", { ...base, baselineText: "hello cruel world\n", spans: [] });
expect(html).toContain("cw-del");
expect(html).toContain("cruel");
});
it("pinned baseline renders clean even with spans (pin→clean, INV-33/#48)", () => {
const html = render("hello brave world\n", {
...base, baselineReason: "pinned", baselineText: "hello brave world\n",
spans: [{ start: 6, end: 12, author: "claude" }],
});
expect(html).not.toContain("cw-ins");
});
it("survives intra-emphasis boundaries (#33 discipline)", () => {
const html = render("a *bold claim* here\n", {
...base, baselineText: "a here\n", spans: [{ start: 2, end: 14, author: "claude" }],
});
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");
});
// Finding 4 (final whole-branch review, session native-surfaces-exec): the
// applied-proposal insertion mark used to hardcode tag: "claude", so a
// human-authored proposal (ProposalView.author = "human") still rendered
// cw-ins-claude. It must follow `p.author` (defaulting to "claude" only
// when the field is absent, per ProposalView's own documented default).
it("a human-authored pending proposal renders its applied text cw-ins-human, not cw-ins-claude", () => {
const src = "hello HUMAN world\n";
const html = render(src, {
...base,
baselineText: "hello world\n",
spans: [],
proposals: [
{ id: "p1", anchorStart: 6, anchorEnd: 11, replaced: "world", replacement: "HUMAN", author: "human" },
],
});
expect(html).toContain('class="cw-ins-human"');
expect(html).not.toContain('class="cw-ins-claude"');
expect(html).toContain("HUMAN");
});
// 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-");
});
});
// Task 7 §2.6 / cross-task review follow-up (plan-mandated, INV-29..31): the
// built-in preview's mermaid fence rendering reuses the F7.1 (#22) pure
// re-emit, same as the sunset webview did — a changed mermaid diagram gets
// intra-diagram change styling, not just the plain current diagram. Ports the
// substance of the E2E assertions Task 8 deleted with the webview
// (`git show 2170a0d^:test/e2e/suite/trackChangesPreview.test.ts`, "a changed
// flowchart augments the emitted mermaid source (#22, INV-29)") as pure unit
// tests here instead — no DOM/webview needed to verify SOURCE augmentation.
describe("intra-diagram mermaid diff in the built-in preview (plan T7 §2.6, INV-29)", () => {
const flowchartBefore = "```mermaid\nflowchart LR\n a --> b\n```\n";
const flowchartAfter = "```mermaid\nflowchart LR\n a --> b\n a --> c\n```\n";
it("a changed flowchart fence is re-emitted with cwAdded styling + a legend", () => {
const html = render(flowchartAfter, { ...base, baselineText: flowchartBefore, spans: [] });
expect(html).toMatch(/classDef cwAdded/);
expect(html).toMatch(/class\s+c\s+cwAdded/);
expect(html).toContain("cw-mermaid-legend");
expect(html).toContain('class="mermaid"');
});
it("an unchanged mermaid fence passes through byte-identical (no diff markup)", () => {
const html = render(flowchartBefore, { ...base, baselineText: flowchartBefore, spans: [] });
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
expect(html).toContain("flowchart LR");
expect(html).toContain("a --&gt; b");
});
it("annotations disabled: a changed mermaid fence renders the plain current diagram", () => {
const html = render(flowchartAfter, { ...base, enabled: false, baselineText: flowchartBefore, spans: [] });
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
expect(html).toContain("a --&gt; c");
});
it("pinned baseline with zero landed diff: mermaid fence renders the plain current diagram (INV-33/#48 parity)", () => {
const html = render(flowchartAfter, {
...base, baselineReason: "pinned", baselineText: flowchartAfter, spans: [],
});
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
});
it("no baseline (no coediting context established): mermaid fence renders the plain current diagram", () => {
const html = render(flowchartAfter, { ...base, baselineText: undefined, spans: [] });
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
});
it("a non-mermaid changed fence is untouched by the mermaid queue (fence-kind gate)", () => {
const before = "```js\nconst a = 1;\n```\n";
const after = "```js\nconst a = 2;\n```\n";
const html = render(after, { ...base, baselineText: before, spans: [] });
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
expect(html).toContain('class="language-js"');
});
// Code-review hardening (705de31 follow-up): the queue used to be consumed
// POSITIONALLY per mermaid fence encountered, so a fence the queue doesn't
// know about (e.g. a pending proposal's optimistic apply, F12/INV-48,
// inserting a whole new mermaid fence ABOVE a changed one) shifted every
// later fence's override by one, substituting the wrong diagram's augmented
// source into the wrong fence instead of the documented verbatim fallback.
// The fix matches queue entries to fences by BODY: a fence with no matching
// entry renders verbatim without consuming anything, so a later matching
// fence still finds its own entry.
it("misalignment: an extra proposal-inserted mermaid fence above a changed one renders verbatim, and the changed fence still gets its own augmentation", () => {
const extraFence = "```mermaid\nsequenceDiagram\n A->>B: hi\n```\n";
const src = extraFence + "\n" + flowchartAfter;
const html = render(src, {
...base,
baselineText: flowchartBefore,
spans: [],
proposals: [{ id: "p1", anchorStart: 0, anchorEnd: extraFence.length, replaced: "", replacement: extraFence }],
});
const fences = html.split('<pre class="mermaid"').slice(1); // one chunk per fence, document order
expect(fences).toHaveLength(2);
// the extra fence has no matching queue entry: verbatim, no wrong-diagram substitution
expect(fences[0]).toContain("sequenceDiagram");
expect(fences[0]).not.toContain("cwAdded");
// the changed fence still finds its own augmentation despite the extra fence ahead of it in document order
expect(fences[1]).toContain("classDef cwAdded");
expect(fences[1]).toMatch(/class\s+c\s+cwAdded/);
expect((html.match(/cw-mermaid-legend/g) ?? []).length).toBe(1); // legend only on the augmented fence
});
// Body-match must not collapse two changed fences that happen to render the
// SAME current diagram into a single queue entry — each occurrence gets its
// own independent augmentation (consume-first-match, not a body->entry map).
it("two identical-body changed mermaid fences each get their own augmentation", () => {
const baselineText = flowchartBefore + "\n" + flowchartBefore;
const src = flowchartAfter + "\n" + flowchartAfter;
const html = render(src, { ...base, baselineText, spans: [] });
const fences = html.split('<pre class="mermaid"').slice(1);
expect(fences).toHaveLength(2);
for (const f of fences) {
expect(f).toContain("classDef cwAdded");
expect(f).toMatch(/class\s+c\s+cwAdded/);
}
expect((html.match(/cw-mermaid-legend/g) ?? []).length).toBe(2);
});
});
+19 -3
View File
@@ -1,6 +1,6 @@
import { describe, it, test, expect } from "vitest";
import MarkdownIt from "markdown-it";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, type AuthorSpan } from "../src/trackChangesModel";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, countLineHunks, type AuthorSpan } from "../src/trackChangesModel";
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
describe("splitBlocks", () => {
@@ -440,9 +440,9 @@ describe("renderReview", () => {
expect(html).toContain('data-proposal-id="p1"'); // proposal still shows (it is an action)
expect(html).toContain("Person");
});
test("renderReview: zero diff (e.g. machine-landing accept) renders unchanged blocks plain (Task 2 behavior)", () => {
test("renderReview: zero diff (baseline == current) renders unchanged blocks plain (Task 2 behavior)", () => {
// Task 2: unchanged blocks always render plain — color means "changed since baseline".
// After accepting a Claude edit the text is the new baseline; no diff → no coloring.
// When baseline == current (e.g. right after a fresh snapshot) there's no diff → no coloring.
const doc = "Human wrote this.\n\nClaude wrote that.";
const spans: AuthorSpan[] = [{ start: 19, end: doc.length, author: "claude" }];
const html = renderReview(doc, doc, spans, []); // no pinned flag
@@ -825,3 +825,19 @@ describe("F11 data-src emission (INV-36)", () => {
expect(renderReview(doc, doc, [], [])).not.toContain('href="https://example.com/spec"');
});
});
describe("countLineHunks", () => {
it("0 for identical text", () => {
expect(countLineHunks("a\nb\n", "a\nb\n")).toBe(0);
});
it("1 for one contiguous change", () => {
expect(countLineHunks("a\nb\nc\n", "a\nX\nc\n")).toBe(1);
});
it("2 for two separated changes", () => {
expect(countLineHunks("a\nb\nc\nd\ne\n", "a\nX\nc\nd\nY\n")).toBe(2);
});
it("counts pure insertions and deletions", () => {
expect(countLineHunks("a\n", "a\nb\n")).toBe(1);
expect(countLineHunks("a\nb\n", "a\n")).toBe(1);
});
});