16 Commits

Author SHA1 Message Date
BenStullsBets 6b95d24269 add sessions/0068/SESSION-0068.0-TRANSCRIPT-2026-07-03T06-53--2026-07-03T07-39.md + replace placeholder/variant SESSION-0068.0-TRANSCRIPT-2026-07-03T06-53--INPROGRESS.md 2026-07-03 07:42:11 -07:00
BenStullsBets 980cf9eefb add sessions/0067/SESSION-0067.0-TRANSCRIPT-2026-07-02T20-07--2026-07-03T07-39.md + replace placeholder/variant SESSION-0067.0-TRANSCRIPT-2026-07-02T20-07--INPROGRESS.md 2026-07-03 07:42:07 -07:00
BenStullsBets ff09c3b915 claim vscode-cowriting-plugin session 0068 (placeholder) + sessions.json entry 2026-07-03 06:54:04 -07:00
BenStullsBets 7a1b30a671 update sessions/0067/SESSION-0067.0-TRANSCRIPT-2026-07-02T20-07--INPROGRESS.md 2026-07-02 20:21:06 -07:00
benstull 3e7ad58247 Surface polish: batched Minor findings from the native-surfaces final review (#71) (#75) 2026-07-03 03:20:23 +00:00
BenStullsBets a90cc06dfa claim vscode-cowriting-plugin session 0067 (placeholder) + sessions.json entry 2026-07-02 20:08:20 -07:00
BenStullsBets 2961378ede add sessions/0066/SESSION-0066.0-TRANSCRIPT-2026-07-02T16-24--2026-07-02T18-54.md + replace placeholder/variant SESSION-0066.0-TRANSCRIPT-2026-07-02T16-24--INPROGRESS.md 2026-07-02 18:55:45 -07:00
BenStullsBets a0a94d76a9 update sessions/0066/SESSION-0066.0-TRANSCRIPT-2026-07-02T16-24--INPROGRESS.md 2026-07-02 18:21:30 -07:00
benstull 7583165354 fix(#70): INV-5 — reject after an interior tweak restores the retained original (PR #73)
Reject on an optimistically-applied proposal now restores the retained original even after interior tweaks: appliedSpans tracked-range fallback (pure shiftTracked, boundary-straddle distrust, close-clears, rebuild-only resync), honest hard failure with a Discard action, INV-16 read-only guards, rejectAll {reverted,skipped} reporting on all batch surfaces, CodeLens reachability at the tracked span. 312 unit + 94/5 host E2E.

Closes #70.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:19:46 +00:00
BenStullsBets 42740f7cc6 update sessions/0066/SESSION-0066.0-TRANSCRIPT-2026-07-02T16-24--INPROGRESS.md 2026-07-02 17:58:16 -07:00
BenStullsBets 6c3e331fc3 claim vscode-cowriting-plugin session 0066 (placeholder) + sessions.json entry 2026-07-02 16:25:26 -07:00
BenStullsBets d8f2d7383a add sessions/0065/SESSION-0065.0-TRANSCRIPT-2026-07-02T06-31--2026-07-02T16-10.md + replace placeholder/variant SESSION-0065.0-TRANSCRIPT-2026-07-02T06-31--INPROGRESS.md 2026-07-02 16:13:33 -07:00
benstull 935fcc35ee Native surfaces migration — evolve the extension onto VS Code's native review surfaces (9-task plan, spec v0.2.1) (#72) 2026-07-02 23:09:37 +00:00
BenStullsBets 93eeaf13b8 update sessions/0065/SESSION-0065.0-TRANSCRIPT-2026-07-02T06-31--INPROGRESS.md 2026-07-02 08:08:18 -07:00
BenStullsBets 26b2212efd claim vscode-cowriting-plugin session 0065 (placeholder) + sessions.json entry 2026-07-02 06:32:05 -07:00
BenStullsBets 0967067cb3 add sessions/0064/SESSION-0064.0-TRANSCRIPT-2026-07-01T22-19--2026-07-02T05-34.md + replace placeholder/variant SESSION-0064.0-TRANSCRIPT-2026-07-01T22-19--INPROGRESS.md 2026-07-02 05:35:06 -07:00
65 changed files with 5350 additions and 2064 deletions
+100 -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
@@ -55,6 +105,14 @@ Design: `vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md`. No li
## F3 — Live human/Claude attribution (Feature #6)
> **Commands superseded (native-surfaces migration).** The attribution **data
> layer** below is current, but the Commands block is historical:
> `Cowriting: Toggle Attribution` was retired — authorship/change coloring now
> toggles via **Toggle Annotations** on a coedited document (How it works §6) —
> and "Ask Claude to Edit Selection" is palette-hidden; use **Ask Claude to
> Edit** (`Ctrl+Alt+E` / `Cmd+Alt+E`) or the comments-first **Ask Claude** (How
> it works §3). Kept below as a historical record.
As you and Claude coauthor, every span in the buffer carries an author: human
edits render with a subtle left border, Claude-authored spans with a background
tint. Text that predates tracking stays plain — the honest record. Edits that
@@ -134,7 +192,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 +233,14 @@ 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. (The
> `Ctrl+Alt+R` chord below now opens the native **Review Changes** diff; the
> annotated preview opens via **Open Cowriting Review Preview**, no chord.)
**`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 +272,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 +318,14 @@ 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. (The `Ctrl+Alt+R`
> chord below now opens the native **Review Changes** diff, not this panel.)
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 +352,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.**
@@ -0,0 +1,526 @@
# #70 — INV-5 Reject-After-Interior-Edit Gap Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `✗ Reject` on an optimistically-applied proposal restores the retained original (INV-5) even after the writer typed inside the pending range — and when it genuinely cannot locate the applied span, it fails loudly instead of reporting success while leaving the proposed text behind.
**Architecture:** Issue #70's fix direction **(a)** with an honest **(b)** fallback. A new per-doc `appliedSpans: Map<proposalId, OffsetRange>` becomes the real F12 applied-range bookkeeping: written at optimistic-apply time, re-synced whenever the exact anchor resolves at `renderAll`, shifted through interior-safe buffer edits (`shift()`), and **deleted (distrusted)** when an edit straddles a span boundary (e.g. a whole-buffer external replace — a shifted range clamps to garbage there, and restoring at a guessed offset would corrupt the doc, INV-11's "never applied by guess"). `revertInPlace` then reverts via exact resolve → `appliedSpans` fallback → hard failure (warning, proposal kept, `false` returned). Direction (c) (fuzzy resolve) is rejected: it guesses. `rejectAll` orders by the same fallback and reports skips like `acceptAllProposals` does.
**Tech Stack:** TypeScript VS Code extension; `npm test` (vitest unit), `npm run test:e2e` (@vscode/test-electron host suite — `ProposalController` is vscode-coupled, so coverage is host E2E, matching every existing F12 test).
## Global Constraints
- INV-5: "Reject restores the retained original exactly" — the contract this fix enforces; `revertInPlace`'s doc comment already promises "reverts the whole block regardless of any in-place edits the human made".
- INV-11: anchors are immutable for a proposal's life; stale text is flagged, **never applied by guess** — the fallback must only use a span whose tracking is provably continuous (no fuzzy resolve, no boundary-straddled ranges).
- INV-12: accept/reject stay human-only gestures; no new mutation paths.
- Keep `✓ Keep` (`finalizeInPlace`) behavior byte-identical — its lookup-by-id bypass of resolution is correct by design (issue "Contrast" note).
- Existing test suites must stay green: 265+ unit, full host E2E (notably `proposals.test.ts` INV-11 stale-accept, `f12InlineDiff.test.ts`, `fullLoop.test.ts`).
- Wiggleverse hygiene: no inline trailing comments in CLI blocks; commits cite the issue and carry the `Co-Authored-By` trailer.
---
### Task 1: `appliedSpans` bookkeeping + `revertInPlace` tracked-range fallback (the INV-5 repro)
**Files:**
- Modify: `src/proposalController.ts` (DocState ~line 35, `ensureState` ~127, `optimisticApply` ~336, `finalizeInPlace` ~417, `revertInPlace` ~434, `renderAll` ~495, `onDidChange` ~529)
- Test: `test/e2e/suite/f12InlineDiff.test.ts` (new suite appended)
**Interfaces:**
- Consumes: `shift(range, edit)` / `resolve(text, fp)` from `src/anchorer.ts` (existing); `DocState.applied: Set<string>` (existing).
- Produces: `DocState.appliedSpans: Map<string, OffsetRange>` (private bookkeeping; Tasks 23 rely on its distrust + ordering semantics); `revertInPlace(docPath, proposalId, opts?: { silent?: boolean }): Promise<boolean>` — signature gains the optional `opts` used by Task 3.
- [ ] **Step 1: Write the failing E2E test — the issue's repro sketch**
Append to `test/e2e/suite/f12InlineDiff.test.ts`:
```typescript
// #70 (INV-5): rejecting a proposal AFTER typing inside its pending range must
// still restore the retained original. The exact-substring anchor is orphaned by
// the interior tweak (INV-1/INV-11) — the revert falls back to the F12
// applied-range bookkeeping (appliedSpans), which shift-tracks the span through
// interior edits.
suite("F12 inline diff — #70 reject after interior edit (INV-5)", () => {
test("revertInPlace restores the original after a tweak inside the pending range", async () => {
const ORIGINAL = "The quick brown fox jumps.";
const { doc } = await freshDoc("docs/s70-reject-tweaked.md", `# T\n\n${ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const docKey = p.keyFor(doc);
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "The rapid brown fox jumps.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
assert.ok(doc.getText().includes("The rapid brown fox jumps."), "optimistically applied");
// Human types INSIDE the pending range → orphans the exact anchor (INV-11).
const at = doc.getText().indexOf("rapid");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + "rapid".length)), "RAPID-ish");
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
await settle();
assert.strictEqual(p.listProposals(doc).find((v) => v.id === id)!.anchorStart, null, "anchor orphaned by the tweak");
// ✗ Reject: must restore the retained original EXACTLY (INV-5), not skip.
assert.ok(await p.revertInPlace(docKey, id!), "reject reports success");
await settle();
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
});
test("rejectByIdInPlace routes the tweaked-applied case the same way", async () => {
const ORIGINAL = "A steady closing line.";
const { doc } = await freshDoc("docs/s70-reject-route.md", `# T\n\n${ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const docKey = p.keyFor(doc);
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "A wobbly closing line.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
const at = doc.getText().indexOf("wobbly");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at)), "very-");
assert.ok(await vscode.workspace.applyEdit(we), "interior insertion applied");
await settle();
assert.ok(await p.rejectByIdInPlace(docKey, id!), "gesture-level reject succeeds");
await settle();
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
});
});
```
- [ ] **Step 2: Run the new suite to verify it fails**
```bash
npm run test:e2e 2>&1 | grep -A 3 "#70"
```
Expected: both tests FAIL — first on `doc.getText()` still holding the tweaked replacement (`RAPID-ish`) after a `true` return (the silent-skip bug), i.e. the "original restored exactly" assertion.
- [ ] **Step 3: Implement `appliedSpans` + the fallback**
In `src/proposalController.ts`:
(1) `DocState` gains the map (after `applied`):
```typescript
/** ids already optimistically applied to the buffer (so the trigger doesn't re-apply). */
applied: Set<string>;
/**
* #70 (INV-5): the F12 applied-range bookkeeping — proposal id -> the applied
* span's CURRENT buffer range. Written at optimistic-apply, re-synced whenever
* the exact anchor resolves (renderAll), shifted through interior-safe edits,
* and DELETED (distrusted) when an edit straddles a span boundary — a clamped
* range is a guess, and stale text is never applied by guess (INV-11). Lets
* Reject restore the original after the human types INSIDE the pending range
* (which orphans the exact-substring anchor).
*/
appliedSpans: Map<string, OffsetRange>;
```
(2) `ensureState` initializes it: `appliedSpans: new Map(),` after `applied: new Set(),`.
(3) `optimisticApply` records the span where it already re-anchors (after `state.applied.add(proposalId);` and the `appliedStart`/`appliedEnd` computation — move the `add` down beside it or record right after the existing lines):
```typescript
state.applied.add(proposalId);
// Re-anchor to the applied text now in the buffer (its start is unchanged; its
// end shifts by the net length delta of the replacement).
const appliedStart = resolved.start;
const appliedEnd = appliedStart + proposal.replacement.length;
state.appliedSpans.set(proposalId, { start: appliedStart, end: appliedEnd });
```
(4) `renderAll`'s resolved branch re-syncs (covers the prior-session-applied reload, where the in-memory map starts empty):
```typescript
} else {
this.recordProposal(state, proposal, resolved, true);
if (state.applied.has(proposal.id)) state.appliedSpans.set(proposal.id, resolved);
}
```
(5) `onDidChange` maintains it — interior-safe edits shift, boundary-straddling edits distrust. Replace the method body:
```typescript
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
const state = this.docs.get(this.keyOf(e.document));
if (!state || (state.live.size === 0 && state.appliedSpans.size === 0)) return;
for (const change of e.contentChanges) {
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
for (const [id, range] of state.live) state.live.set(id, shift(range, edit));
// #70: an edit fully inside a tracked applied span (or fully outside it)
// keeps the span meaningful; one straddling a boundary — e.g. a whole-buffer
// external replace — makes the shifted range a clamped guess, so the span
// is distrusted (deleted) rather than reverted-to by guess (INV-11).
for (const [id, range] of state.appliedSpans) {
const outside = edit.end <= range.start || edit.start >= range.end;
const inside = edit.start >= range.start && edit.end <= range.end;
if (outside || inside) state.appliedSpans.set(id, shift(range, edit));
else state.appliedSpans.delete(id);
}
}
}
```
(6) `revertInPlace` — the fix itself. Replace the method (keep its position; doc comment updated to describe the layered lookup; `opts` is consumed by Task 3):
```typescript
/**
* F12/#64 (INV-51): REJECT an optimistically-applied proposal — replace its live
* applied span with the stored `original`, then clear it. Reverts the whole block
* regardless of any in-place edits the human made to the inserted text: when an
* interior tweak has orphaned the exact anchor (INV-11), the revert falls back to
* the shift-tracked applied span (#70, INV-5). When neither locates the span
* (e.g. a boundary-straddling external rewrite distrusted it), the reject FAILS
* loudly — warning shown, proposal kept, `false` returned — instead of silently
* leaving the proposed text in the buffer. Undo (or ✓ Keep) remains the way out.
*/
async revertInPlace(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
const document = this.openDoc(hit.state);
if (!document) return false;
// Never optimistically applied → nothing of ours is in the buffer; clearing
// the record IS the reject (the legacy pending-only path).
if (hit.proposal.original === undefined) {
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
hit.state.appliedSpans.delete(proposalId);
this.renderAll(document);
return true;
}
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
const span = resolved !== "orphaned" ? resolved : hit.state.appliedSpans.get(proposalId);
if (!span) {
if (!opts?.silent) {
void vscode.window.showWarningMessage(
"Cowriting: this proposal's applied text can't be located (it changed or moved) — undo your edits, or Keep it to leave the buffer as-is (it is never reverted by guess).",
);
}
return false;
}
const we = new vscode.WorkspaceEdit();
we.replace(
document.uri,
new vscode.Range(document.positionAt(span.start), document.positionAt(span.end)),
hit.proposal.original,
);
if (!(await vscode.workspace.applyEdit(we))) return false;
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
hit.state.appliedSpans.delete(proposalId);
this.renderAll(document);
return true;
}
```
(7) `finalizeInPlace` cleans the map beside its existing `applied.delete`:
```typescript
hit.state.applied.delete(proposalId);
hit.state.appliedSpans.delete(proposalId);
```
- [ ] **Step 4: Typecheck + unit tests + the new E2E**
```bash
npm run typecheck && npm test
npm run test:e2e
```
Expected: typecheck clean; all unit tests PASS (no unit test touches `ProposalController`); host E2E PASS including the two new #70 tests and all pre-existing F12/proposals/fullLoop suites.
- [ ] **Step 5: Commit**
```bash
git add src/proposalController.ts test/e2e/suite/f12InlineDiff.test.ts
git commit -m "fix(#70): reject after an interior tweak restores the original via tracked applied span (INV-5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 2: The honest hard-failure path (fix direction (b) for the residual orphan)
**Files:**
- Modify: `src/proposalController.ts` (only if Step 2 exposes a gap — the Task 1 code already implements the path)
- Test: `test/e2e/suite/f12InlineDiff.test.ts` (extend the #70 suite)
**Interfaces:**
- Consumes: Task 1's `appliedSpans` distrust semantics and `revertInPlace` failure contract.
- Produces: nothing new — locks the contract with a regression test.
- [ ] **Step 1: Write the failing/locking E2E test**
Append inside the `#70` suite from Task 1:
> **Execution note:** the first cut of this test used a whole-buffer rewrite as the
> distrusting edit and FAILED — VS Code minimizes workspace edits before change
> events fire, so a rewrite sharing a prefix/suffix with the old text decomposes
> into interior hunks the span legitimately survives (and the revert then correctly
> restores the original in place). The distrusting edit must straddle a span
> boundary even after minimization — a deletion from outside the span into its
> interior, as below.
```typescript
// Fix direction (b): when the tracked span itself is distrusted (an edit
// straddled its boundary — here a deletion running from the heading into the
// span's interior), the reject FAILS honestly: warning path, proposal kept,
// buffer untouched. Silent-skip-and-report-success (the #70 bug) must not come
// back; reverting at a clamped guessed offset (INV-11) must not either.
// (A boundary-straddling DELETION is used because VS Code minimizes workspace
// edits before change events fire — a wholesale rewrite sharing a prefix/suffix
// decomposes into interior hunks the span legitimately survives.)
test("reject fails loudly — proposal kept, buffer untouched — when the span is distrusted", async () => {
const ORIGINAL = "Sentence one stands here.";
const { doc } = await freshDoc("docs/s70-reject-distrust.md", `# T\n\n${ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const docKey = p.keyFor(doc);
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "Sentence ONE stands here.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
// Delete from inside the heading through the middle of the applied span:
// the edit straddles the span's start boundary → the tracked span is
// distrusted AND the exact anchor no longer resolves.
const mid = doc.getText().indexOf("ONE");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(2), doc.positionAt(mid + 1)), "");
assert.ok(await vscode.workspace.applyEdit(we), "boundary-straddling deletion applied");
await settle();
const before = doc.getText();
assert.strictEqual(await p.revertInPlace(docKey, id!), false, "reject reports failure (no silent success)");
await settle();
assert.strictEqual(doc.getText(), before, "buffer untouched — never reverted by guess (INV-11)");
assert.ok(p.listProposals(doc).some((v) => v.id === id), "proposal kept (not silently dropped)");
// Cleanup for later tests: the never-locatable husk is discarded via the
// plain record-only reject.
assert.strictEqual(p.rejectById(docKey, id!), true);
});
```
- [ ] **Step 2: Run it**
```bash
npm run test:e2e 2>&1 | grep -B 2 -A 5 "distrusted"
```
Expected: PASS if Task 1's implementation is complete (this is the locking regression); if it FAILS, the failure pinpoints which leg (distrust bookkeeping vs. failure return vs. proposal retention) is wrong — fix `src/proposalController.ts` accordingly, not the test.
- [ ] **Step 3: Commit**
```bash
git add test/e2e/suite/f12InlineDiff.test.ts src/proposalController.ts
git commit -m "test(#70): lock the honest hard-failure reject path (distrusted span → warn, keep, false)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 3: `rejectAll` parity — tweaked proposals revert too, skips are reported
**Files:**
- Modify: `src/proposalController.ts` (`rejectAll` ~line 461), `src/extension.ts` (`cowriting.rejectAllProposals` handler ~line 220)
- Test: `test/e2e/suite/f12InlineDiff.test.ts` (extend the #70 suite)
**Interfaces:**
- Consumes: Task 1's `revertInPlace(docPath, id, opts?: { silent?: boolean })` and `appliedSpans`.
- Produces: `rejectAll(document): Promise<{ reverted: number; skipped: number }>` — additive `skipped` field (existing `{ reverted }` destructurings stay valid).
- [ ] **Step 1: Write the failing E2E test**
Append inside the `#70` suite:
```typescript
// rejectAll must revert a tweaked (orphaned-anchor) proposal via the same
// tracked-span fallback, ordered descending by that span so earlier reverts
// never shift later ones — and must count what it could not revert.
test("rejectAll reverts tweaked proposals via the tracked span and reports skips", async () => {
const { doc } = await freshDoc("docs/s70-rejall-tweak.md", "# T\n\nOne aaa.\n\nTwo bbb.\n");
const api = await getApi();
const p = api.proposalController;
const editFlow = api.editFlow;
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();
// Tweak INSIDE the first applied block → its exact anchor orphans.
const at = doc.getText().indexOf("AAA");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 3)), "AAAZ");
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
await settle();
const { reverted, skipped } = await p.rejectAll(doc);
await settle();
assert.strictEqual(reverted, ids.length, "ALL proposals reverted, tweaked one included");
assert.strictEqual(skipped, 0, "nothing skipped");
assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document fully restored (INV-5)");
assert.strictEqual(p.listProposals(doc).length, 0, "all cleared");
});
```
- [ ] **Step 2: Run it to verify the current gap**
```bash
npm run test:e2e 2>&1 | grep -B 2 -A 5 "rejectAll reverts tweaked"
```
Expected: FAIL — today the tweaked proposal sorts as an orphan (`start: -1`, reverted last), and after the first successful revert's `renderAll` the old code silently removes it without restoring (`document fully restored` assertion fails), and `skipped` does not exist on the return type (typecheck catches first — that is the same failure).
- [ ] **Step 3: Implement — order by the fallback span, count skips, report them**
Replace `rejectAll` in `src/proposalController.ts`:
```typescript
/**
* F12/#64 (INV-53): reject EVERY pending proposal on a document — revert each in
* DESCENDING span order (so an earlier revert never shifts a later one's
* offsets), symmetric with #46's accept-all. #70: a tweaked proposal whose exact
* anchor is orphaned orders (and reverts) by its shift-tracked applied span;
* one with no locatable span is SKIPPED — counted, never guessed (INV-11) —
* and the per-proposal warning is suppressed in favor of the batch report.
*/
async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number; skipped: number }> {
if (!this.isTracked(document)) return { reverted: 0, skipped: 0 };
const docPath = this.keyOf(document);
const state = this.ensureState(document);
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
const text = document.getText();
const ordered = state.artifact.proposals
.map((p) => {
const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
const r = fp ? resolve(text, fp) : "orphaned";
const start = r !== "orphaned" ? r.start : state.appliedSpans.get(p.id)?.start ?? -1;
return { id: p.id, start };
})
.sort((a, b) => b.start - a.start);
let reverted = 0;
let skipped = 0;
for (const it of ordered) {
if (await this.revertInPlace(docPath, it.id, { silent: true })) reverted++;
else skipped++;
}
return { reverted, skipped };
}
```
And in `src/extension.ts`, the `cowriting.rejectAllProposals` handler mirrors accept-all's skip note:
```typescript
const { reverted, skipped } = await proposalController.rejectAll(doc);
if (reverted === 0 && skipped === 0) return;
const skipNote = skipped > 0 ? `, ${skipped} skipped (applied text not locatable — undo your edits or Keep)` : "";
void vscode.window.showInformationMessage(
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}${skipNote}.`,
);
```
- [ ] **Step 4: Typecheck + full test run**
```bash
npm run typecheck && npm test
npm run test:e2e
```
Expected: all PASS — including the pre-existing `rejectAll` E2E tests (`f12InlineDiff` "rejectAll reverts every pending proposal", "control parity", `proposals.test.ts` cleanup calls), whose `{ reverted }` destructuring is unaffected by the additive field.
- [ ] **Step 5: Commit**
```bash
git add src/proposalController.ts src/extension.ts test/e2e/suite/f12InlineDiff.test.ts
git commit -m "fix(#70): rejectAll reverts tweaked proposals via the tracked span; skips are counted and reported
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 4: Close the loop — fullLoop E2E rejects a tweaked proposal (the deliberate avoidance falls)
**Files:**
- Modify: `test/e2e/suite/fullLoop.test.ts` (the PUC-2 reject section, ~line 144)
**Interfaces:**
- Consumes: the shipped Task 1 behavior; existing fullLoop fixtures (`rejectId`, `REJECT_REPLACEMENT`, `ORIG_REJECT`).
- Produces: nothing — upgrades the flagship E2E so the gap can't silently regress.
- [ ] **Step 1: Tweak inside the reject proposal's range before rejecting**
In `test/e2e/suite/fullLoop.test.ts`, immediately before the existing line
`assert.ok(await api.proposalController.revertInPlace(docKey, rejectId), "reject reverts in place");`,
insert:
```typescript
// #70 (INV-5): the reject leg now ALSO takes an interior tweak first — the
// original deliberately rejected only an un-tweaked proposal because the
// pre-fix revert silently skipped an orphaned anchor. The tweak orphans the
// exact anchor; the revert must restore ORIG_REJECT via the tracked span.
const rejAt = doc.getText().indexOf(REJECT_REPLACEMENT);
assert.ok(rejAt >= 0, "reject proposal's applied text present");
const rejTweak = new vscode.WorkspaceEdit();
rejTweak.replace(doc.uri, new vscode.Range(doc.positionAt(rejAt + 2), doc.positionAt(rejAt + 2)), "x");
assert.ok(await vscode.workspace.applyEdit(rejTweak), "human tweak inside the reject proposal's range");
await settle();
assert.strictEqual(
api.proposalController.listProposals(doc).find((v) => v.id === rejectId)!.anchorStart,
null,
"tweak orphans the reject proposal's exact anchor (INV-11)",
);
```
(The existing assertions right after — proposal cleared, `ORIG_REJECT` restored, `REJECT_REPLACEMENT` gone — now verify the #70 path. If `REJECT_REPLACEMENT` is a multi-word string whose slice at `+2` splits a word the later `!doc.getText().includes(REJECT_REPLACEMENT)` assertion still holds; the `includes(ORIG_REJECT)` restore assertion is the INV-5 check.)
- [ ] **Step 2: Run the fullLoop suite**
```bash
npm run test:e2e 2>&1 | grep -B 2 -A 8 "fullLoop\|full-loop\|full loop"
```
Expected: PASS end-to-end (would FAIL on unfixed code: the buffer would keep the tweaked `REJECT_REPLACEMENT`, so `includes(ORIG_REJECT)` fails).
- [ ] **Step 3: Full verification sweep**
```bash
npm run typecheck && npm test && npm run test:e2e
```
Expected: everything green.
- [ ] **Step 4: Commit**
```bash
git add test/e2e/suite/fullLoop.test.ts
git commit -m "test(#70): fullLoop reject leg now tweaks inside the pending range first (INV-5 end-to-end)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
## Post-execution review wave (not in the original tasks)
A high-effort multi-agent branch review after Task 4 confirmed 10 findings; all
but one were fixed in a follow-up commit on this branch ("harden the
tracked-span revert per branch review"): pure `shiftTracked` in `anchorer.ts`
(+11 unit tests; insertion-at-span-start lands before the span), tracked spans
cleared on document close, rebuild-only resync at `renderAll`, `guard.isReadOnly`
on `revertInPlace`/`finalizeInPlace`, a "Discard proposal (leave text)" action on
the hard-fail warning, CodeLens pair anchored at the tracked span (+1 E2E),
QuickPick batch menu routed through the reporting commands, and one
`clearProposal` helper (also fixes `reject()`'s stale `applied`/`appliedSpans`).
The remaining finding — `resolve()` accepts a single exact occurrence without a
context check, so a duplicated block can hijack any anchor consumer — pre-dates
this fix and is filed as its own issue.
## Self-review notes
- **Issue coverage:** direction (a) → Task 1 (`appliedSpans` fallback); direction (b) → Task 2 (hard failure, warn, keep); direction (c) explicitly rejected (guessing violates INV-11). The issue's repro sketch is Task 1's test verbatim; the "full-loop E2E deliberately rejects only an un-tweaked proposal" note is retired by Task 4; `rejectAll` (same `revertInPlace` seam) is covered by Task 3.
- **Type consistency:** `revertInPlace(docPath, proposalId, opts?: { silent?: boolean })` defined in Task 1, consumed in Task 3; `rejectAll` returns `{ reverted, skipped }` (additive); `appliedSpans: Map<string, OffsetRange>` used in Tasks 1 and 3.
- **Contrast guard:** `finalizeInPlace` untouched except symmetric `appliedSpans.delete` cleanup — the Keep bypass stays.
@@ -0,0 +1,342 @@
# #71 Surface Polish Batch Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Land the six batched Minor findings from the native-surfaces migration's final whole-branch review (issue #71) — misleading `pin()` warning, orphaned command declarations, a wrong when-clause key, README F3/banner drift, an ungated palette entry, and an uncleared debounce timer.
**Architecture:** Six independent point fixes across `src/diffViewController.ts`, `src/gitBaseline.ts`, `package.json`, and `README.md`. No new modules, no model/persistence changes, no new invariants. Verification is the existing suites (all six items are copy/declaration/cleanup changes with no unit-testable pure surface — the unit suite is vscode-free and these all live at the vscode boundary; E2E cannot observe warning-message text or palette visibility).
**Tech Stack:** VS Code extension (TypeScript, esbuild CJS bundle), vitest unit suite (`npm test`), `@vscode/test-electron` host E2E (`npm run test:e2e`).
## Global Constraints
- Work happens on branch `worktree-s71-surface-polish` in the worktree at `.claude/worktrees/s71-surface-polish` (canonical checkout is occupied by concurrent session 0062 — never touch it).
- Commits cite #71, read like surrounding history, and carry the `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` trailer.
- Warning copy for the not-coediting case is exactly `"Run ✦ Coedit this Document with Claude first."` — the string `src/threadController.ts:181` already uses. Do not coin a variant.
- No inline trailing comments on shell commands (wgl `no-inline-cli-comments`).
- Final gate before PR: `npm test` (unit), `npm run typecheck`, `npm run build`, `npm run test:e2e` all green.
---
### Task 1: `pin()` explicit not-coediting branch (item 1)
**Files:**
- Modify: `src/diffViewController.ts:145-155`
**Interfaces:**
- Consumes: `this.modes: Map<string, "head" | "snapshot">` (set only by `establish()`).
- Produces: nothing new — same `pin(document): void` signature; only the warning copy forks.
`pin()` currently emits "this document is git-tracked — commit to advance the baseline" for **any** non-snapshot mode, including `undefined` (a document that never established, i.e. is not being coedited) — a wrong message for that case.
- [ ] **Step 1: Fork the guard on `undefined`**
Replace lines 145155 (the whole `pin` method) with:
```ts
pin(document: vscode.TextDocument): void {
if (!this.isDiffable(document)) return;
const key = this.uriKey(document);
const mode = this.modes.get(key);
if (mode === undefined) {
// never established — the document is not being coedited (#71 item 1)
void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first.");
return;
}
if (mode !== "snapshot") {
void vscode.window.showWarningMessage(
"Cowriting: this document is git-tracked — commit to advance the baseline.",
);
return;
}
this.capture(document, "pinned");
}
```
Keep the existing doc comment above the method unchanged.
- [ ] **Step 2: Verify**
Run: `npm run typecheck`
Expected: clean exit.
- [ ] **Step 3: Commit**
```bash
git add src/diffViewController.ts
git commit -m "fix: markReviewed on a never-established doc says 'coedit first', not 'git-tracked' (#71 item 1)"
```
---
### Task 2: delete orphaned `acceptProposal` / `rejectProposal` declarations (item 2)
**Files:**
- Modify: `package.json` (two `contributes.commands` entries ~85-93, two `contributes.menus.commandPalette` entries ~180-187)
**Interfaces:** none — pure declaration removal. `test/e2e/suite-no-workspace/noWorkspace.test.ts:42-43` already asserts these commands are **not registered** at runtime; nothing registers them in `src/`.
- [ ] **Step 1: Delete the two command declarations**
In `contributes.commands`, delete both objects:
```json
{
"command": "cowriting.acceptProposal",
"title": "✓ Accept Proposal",
"category": "Cowriting"
},
{
"command": "cowriting.rejectProposal",
"title": "✗ Reject Proposal",
"category": "Cowriting"
},
```
- [ ] **Step 2: Delete their palette-hiding menu entries**
In `contributes.menus.commandPalette`, delete both objects (they exist only to hide the now-deleted declarations):
```json
{
"command": "cowriting.acceptProposal",
"when": "false"
},
{
"command": "cowriting.rejectProposal",
"when": "false"
},
```
- [ ] **Step 3: Verify no dangling references**
Run: `grep -rn "acceptProposal\|rejectProposal" package.json src/`
Expected: no matches (the only remaining references are the retirement assertions in `test/e2e/suite-no-workspace/noWorkspace.test.ts`).
Run: `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('valid json')"`
Expected: `valid json`
- [ ] **Step 4: Commit**
```bash
git add package.json
git commit -m "chore: delete orphaned acceptProposal/rejectProposal declarations (#71 item 2)"
```
---
### Task 3: `openReviewPreview` editor/title when-key (item 3)
**Files:**
- Modify: `package.json:260` (the `editor/title` menu entry for `cowriting.openReviewPreview`)
**Interfaces:** none — one-token when-clause fix. Title menus key off the resource, so `resourceLangId` is correct (every sibling `editor/title` entry already uses it); `editorLangId` is the odd one out.
- [ ] **Step 1: Fix the key**
In `contributes.menus."editor/title"`, change:
```json
{
"command": "cowriting.openReviewPreview",
"when": "editorLangId == markdown",
"group": "navigation@9"
}
```
to:
```json
{
"command": "cowriting.openReviewPreview",
"when": "resourceLangId == markdown",
"group": "navigation@9"
}
```
(Only the `editor/title` entry. The `commandPalette` entries elsewhere legitimately use `editorLangId`; the `editor/title/context` and `explorer/context` entries already use `resourceLangId`.)
- [ ] **Step 2: Commit**
```bash
git add package.json
git commit -m "fix: openReviewPreview title-menu when uses resourceLangId like its siblings (#71 item 3)"
```
---
### Task 4: gate the `cowriting.createThread` palette entry (item 5)
**Files:**
- Modify: `package.json` (`contributes.menus.commandPalette`)
**Interfaces:** none — `createThreadOnSelection` (`src/threadController.ts:197-201`) already returns `undefined` silently without a selection / coediting / authorable scheme; the palette entry is currently unconditionally visible, so the command silently no-ops from the palette. Fix = gate the palette entry with the **same when-clause its `editor/context` entry uses** (`package.json:290-293`), so the palette only offers it when it can act — consistent with how `markReviewed`'s palette gate was tightened at merge.
- [ ] **Step 1: Add the palette gate**
In `contributes.menus.commandPalette`, insert after the `cowriting.proposeAgentEdit` entry:
```json
{
"command": "cowriting.createThread",
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting"
},
```
- [ ] **Step 2: Verify JSON**
Run: `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('valid json')"`
Expected: `valid json`
- [ ] **Step 3: Commit**
```bash
git add package.json
git commit -m "fix: gate createThread palette entry on selection+coediting like its context-menu entry (#71 item 5)"
```
---
### Task 5: clear the `gitBaseline` reflog debounce timer in `dispose()` (item 6)
**Files:**
- Modify: `src/gitBaseline.ts:113-126` (`watchReflog`)
**Interfaces:** none — same disposable contract; the pushed disposable additionally clears the pending debounce timeout so no one-shot `repo.status()` fires post-dispose.
- [ ] **Step 1: Clear the timer in the pushed disposable**
In `watchReflog`, change:
```ts
const watcher = fs.watch(reflog, () => {
if (pending) clearTimeout(pending);
pending = setTimeout(() => void repo.status().catch(() => {}), 150);
});
this.disposables.push({ dispose: () => watcher.close() });
```
to:
```ts
const watcher = fs.watch(reflog, () => {
if (pending) clearTimeout(pending);
pending = setTimeout(() => void repo.status().catch(() => {}), 150);
});
this.disposables.push({
dispose: () => {
watcher.close();
if (pending) clearTimeout(pending);
},
});
```
- [ ] **Step 2: Verify**
Run: `npm run typecheck`
Expected: clean exit.
- [ ] **Step 3: Commit**
```bash
git add src/gitBaseline.ts
git commit -m "fix: clear reflog debounce timer on GitBaselineWatcher dispose (#71 item 6)"
```
---
### Task 6: README F3 drift + stale chord notes in superseded banners (item 4)
**Files:**
- Modify: `README.md` (F3 section ~106, F7 banner ~230-233, F10 banner ~313-317)
**Interfaces:** none — docs only. Current reality the banner must state: `Cowriting: Toggle Attribution` no longer exists (annotations toggle via **Toggle Annotations**, How it works §6); "Ask Claude to Edit Selection" is palette-hidden (`package.json:201-203``"when": "false"`), superseded by **Ask Claude to Edit** (`Ctrl+Alt+E` / `Cmd+Alt+E`) and the comments-first **Ask Claude** (§3). `Ctrl+Alt+R` today is **Review Changes** (native diff), not any preview.
- [ ] **Step 1: Add the F3 banner**
Immediately after the `## F3 — Live human/Claude attribution (Feature #6)` heading (before the "As you and Claude coauthor…" paragraph), insert:
```markdown
> **Commands superseded (native-surfaces migration).** The attribution **data
> layer** below is current, but the Commands block is historical:
> `Cowriting: Toggle Attribution` was retired — authorship/change coloring now
> toggles via **Toggle Annotations** on a coedited document (How it works §6) —
> and "Ask Claude to Edit Selection" is palette-hidden; use **Ask Claude to
> Edit** (`Ctrl+Alt+E` / `Cmd+Alt+E`) or the comments-first **Ask Claude** (How
> it works §3). Kept below as a historical record.
```
- [ ] **Step 2: Chord note in the F7 banner**
Extend the F7 superseded banner's last sentence. Change:
```markdown
> **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.
```
to:
```markdown
> **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. (The
> `Ctrl+Alt+R` chord below now opens the native **Review Changes** diff; the
> annotated preview opens via **Open Cowriting Review Preview**, no chord.)
```
- [ ] **Step 3: Chord note in the F10 banner**
Change the F10 banner's last sentence from:
```markdown
> **How it works** above. Kept below as a historical record.
```
to:
```markdown
> **How it works** above. Kept below as a historical record. (The `Ctrl+Alt+R`
> chord below now opens the native **Review Changes** diff, not this panel.)
```
- [ ] **Step 4: Commit**
```bash
git add README.md
git commit -m "docs: F3 superseded-commands banner + stale-chord notes in F7/F10 banners (#71 item 4)"
```
---
### Task 7: full verification + PR + merge
**Files:** none new.
- [ ] **Step 1: Full local gate**
Run each; all must be green:
```bash
npm test
npm run typecheck
npm run build
npm run test:e2e
```
Expected: unit suite passes (~250 tests), typecheck clean, build succeeds, host E2E passes (undo suites may self-skip via the #54 runtime probe — a loud warning, not a failure).
- [ ] **Step 2: Push branch + open PR**
```bash
git push -u origin worktree-s71-surface-polish
```
Open a PR against `main` on `benstull/vscode-cowriting-plugin` (Gitea, SSH remote) titled `Surface polish: batched Minor findings from the native-surfaces final review (#71)`; body lists the six items and cites the issue.
- [ ] **Step 3: Merge (squash) and confirm #71 auto-close/close**
Merge the PR; verify `main` carries the change and close #71 if the merge didn't.
+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();
});
+126 -36
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."
}
}
},
@@ -69,29 +81,26 @@
"title": "Ask Claude to Edit Selection",
"category": "Cowriting"
},
{
"command": "cowriting.acceptProposal",
"title": "✓ Accept Proposal",
"category": "Cowriting"
},
{
"command": "cowriting.rejectProposal",
"title": "✗ Reject Proposal",
"category": "Cowriting"
},
{
"command": "cowriting.proposeAgentEdit",
"title": "Propose Agent Edit (internal seam)",
"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 +127,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": {
@@ -131,16 +168,16 @@
"when": "false"
},
{
"command": "cowriting.acceptProposal",
"when": "false"
"command": "cowriting.createThread",
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting"
},
{
"command": "cowriting.rejectProposal",
"when": "false"
"command": "cowriting.reviewChanges",
"when": "editorLangId == markdown && cowriting.isCoediting"
},
{
"command": "cowriting.pinDiffBaseline",
"when": "editorLangId == markdown"
"command": "cowriting.markReviewed",
"when": "editorLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot"
},
{
"command": "cowriting.edit",
@@ -154,13 +191,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,26 +222,46 @@
],
"editor/title": [
{
"command": "cowriting.showTrackChangesPreview",
"when": "editorLangId == markdown",
"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": "resourceLangId == markdown",
"group": "navigation@9"
}
],
"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 +269,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 +342,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();
@@ -0,0 +1,58 @@
# Session 0064.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-07-01T22-19 (PST) · End: 2026-07-02T05-34 (PST)
> Type: writing-plans
> Posture: careful
> Claude-Session: 7c03a44e-1f92-4445-afd9-928efd55afba
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-wp (isolated worktree; torn down at finalize)
> Anchor: design specs/coauthoring-native-surfaces.md (v0.2.1, graduated)
## Launch prompt
> Got it. Let's implement the full plugin
> (operator, after driving the rung-2 feasibility spike; context: evolve the shipped
> extension in place onto specs/coauthoring-native-surfaces.md, D17/§6.10/§7.1)
## Pre-state
- Native-surfaces Solution Design at **v0.2.0, draft**, on content-repo branch `session-0063` (session 0063 INPROGRESS, awaiting operator review). Content `main` had never received the 0062/0063 spec lineage.
- Rung-2 feasibility spike: E1/E3/E5 PASS (recorded in v0.2.0); **E2/E4 unvalidated**. Earlier in this same conversation (pre-session), the throwaway prototype `vscode-cowriting-prototype` was rebuilt to drive the FULL v0.2.0 loop (enter → ask-via-comment → pending-in-buffer → tweak → baseline/diff → preview → stop) and the operator drove it end to end.
- Three concurrent INPROGRESS sessions (0061/0062/0063); 0062 occupied the main plugin checkout → this session claimed from an isolated worktree (#134 gate).
## Arc (turn by turn, condensed)
1. **Spike driving (pre-claim, same conversation):** operator drove the walkthrough; two E4 defects surfaced and were fixed live in the prototype — (a) "Make this edit" gated on a nonexistent `commentThreadContextValue` key (real key: **`commentThread`**); (b) Ask-Claude didn't focus the comment input (fixed via **`workbench.action.addComment`**); (c) "cursor must be within a commenting range" → commenting ranges are **cached per document**; re-assigning `commentingRangeProvider` is the only refresh signal. All recorded in the prototype's FINDINGS.md.
2. **Gate + claim:** operator asked to "implement the full plugin" → `wgl-writing-plans` init; claim refused the shared checkout (live 0062) → isolated worktree `vscode-cowriting-plugin-wp`, claimed **0064** (`--type writing-plans`, careful).
3. **Eligibility:** `check-issue-eligibility.sh --for implementation-plan --design …coauthoring-native-surfaces.md`**ELIGIBLE (R2a)**. Design-readiness: N/A (spec §5.1 = Code directly, no UI design).
4. **Sequencing decision (operator):** fold spike findings first, then plan. Operator confirmed the drive reached step 5+ → **E2 PASS**; E4 **PASS-with-fallback**.
5. **Spec v0.2.1 delta** (content worktree branch `session-0064` off `session-0063`): §6.4 spike-verified API seams (commenting-range refresh, addComment focus, `commentThread` when-key, offer affordances; baseline nested-repo + `state.HEAD.commit` notes), §7.1 rung 2 closed (E2 PASS / E4 PASS-with-fallback; D15 preview-selection half explicitly un-spiked), §7.3 risks updated. Pushed; operator **approved → graduated**.
6. **Plan written** (`superpowers:writing-plans`; codebase mapped via an Explore subagent + direct reads): `docs/superpowers/plans/2026-07-01-native-surfaces-migration.md` — ONE plan, 9 tasks in §7.1 rung-4 order: (1) CoeditingRegistry (INV-10) → (2) baseline router git-HEAD+snapshot (INV-7; **retires INV-18** machine-landing advance) → (3) QuickDiff + `cowriting-baseline:` + Review Changes + status bar (INV-13) → (4) gate every surface → (5) extract EditFlow from the review webview → (6) comments-first ask + comment→reply→offer→proposal loop (D19/D10/D8; sunsets the input webview) → (7) built-in-preview annotations via markdown-it sentinels (D3/D21; Q4 mermaid stop-and-record check) → (8) sunset the review webview + ✓ Keep/✗ Reject copy + README → (9) full-loop E2E + real-SDK smoke (rung 3 folded in). D15 explicitly deferred. Committed to plugin branch `session-0064`, pushed.
7. **Review gate:** operator approved — explicitly accepting the **D15 deferral** and the **INV-18 retirement** — and directed execution to a new session.
8. **Finalize (single approved plan):** content `session-0064` merged → content `main` (landed the whole 0062/0063/0064 spec lineage; flagged), plugin `session-0064` fast-forwarded → plugin `main`; plan archived via `submit-plan.sh` → content `plans/2026-07-01-native-surfaces-migration.md` (ddaf3b0); **issue #69** filed (D15, feature P2, resume context in body); both session worktrees removed; memory updated.
## Deferred decisions
_None — the two judgment calls (D15 deferral, INV-18 retirement) were put to the operator explicitly and approved at the plan gate._
## Cut state
- **Spec:** `coauthoring-native-surfaces.md` **v0.2.1 `graduated`**, on content `main`.
- **Plan:** approved + archived at content `plans/2026-07-01-native-surfaces-migration.md`; also on plugin `main` under `docs/superpowers/plans/`. **Not executed** (by design — writing-plans is terminal).
- **Spike:** complete (5/5); prototype **retained deliberately** (operator: iterate UX there before the real build) — supersedes the spike-plan's delete-when-done for now.
- **Tracker:** #69 (D15 preview-initiated comments, feature P2) parked with full resume context.
- Leftover: merged-but-undeleted local branch refs (`session-wp-native-surfaces` in the plugin clone, `session-0064` in the content clone) — operator declined force-deletion; plain `-d` from a main checkout clears them. Sessions 0061/0062/0063 remain INPROGRESS (0062/0063 will find their content branches already merged).
## Operator plate
- Open the executing session (below) when ready.
- Sessions 0061/0062/0063 are still unfinalized — worth closing out.
- Optional tidy: delete the two stale local branch refs.
## Next session
```
/goal Execute the native-surfaces migration plan (content repo plans/2026-07-01-native-surfaces-migration.md, 9 tasks, from graduated spec coauthoring-native-surfaces.md v0.2.1) end to end
```
(Open with `wgl-executing-plans`; the plan's tasks run 1→9 strictly, each ending typecheck+unit+host-E2E green; Task 9's live smoke is the operator-run rung-3 gate.)
@@ -1,23 +0,0 @@
# Session 0064.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-07-01T22-19 (PST)
> Type: writing-plans
> Posture: careful
> Claude-Session: 7c03a44e-1f92-4445-afd9-928efd55afba
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-wp
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0064 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0064.0-TRANSCRIPT-2026-07-01T22-19--<end>.md form at session end.
## Launch prompt
_(launch prompt not captured at claim time)_
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
@@ -0,0 +1,68 @@
# Session 0065.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-07-02T06-31 (PST)
> End: 2026-07-02T16-10 (PST)
> Type: executing-plans
> Posture: yolo
> Claude-Session: 32dcda47-7361-4fa7-8145-9d118b5de3cc
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin/.claude/worktrees/session-native-surfaces-exec (isolated worktree, branch `session-0065`)
> Status: FINALIZED
## Launch prompt
> The last session should have set a goal. If not, find the spec that we should be implementing
## Plan
- **Anchor:** graduated Solution Design `coauthoring-native-surfaces.md` v0.2.1 (content repo, on main) — R2(a), ELIGIBLE per `check-issue-eligibility.sh`; lineage: sessions 0062 (spec) → 0063 (v0.2.0) → 0064 (spike + plan approved).
- **Goal:** execute the reviewed implementation plan `plans/2026-07-01-native-surfaces-migration.md` — 9 tasks in §7.1 rung-4 order — and land it (no §9 web pipeline: VS Code extension, spec §7.2; done = merged + suites green + rung-3 smoke).
- **Isolation:** sessions 0061/0062/0063 live-concurrent; 0062 occupied the primary checkout → claim refused shared tree (plugin #134) → EnterWorktree `session-native-surfaces-exec`, branch renamed `session-0065`.
- Approved deliberate changes carried by the plan: INV-18 retirement (no machine-landing baseline advance); D15 deferred as issue #69.
## Pre-state
- Plugin repo `main` at `ef61b14` (0064's plan-archive commit); clean; 265 unit + full host E2E green at baseline (verified before any work).
- Content repo `main` at `ddaf3b0` (0064 lineage merged); local content clone behind origin + carrying other sessions' uncommitted drafts — never touched; spec read via `git show origin/main:`.
## Arc (turn-by-turn, compressed)
1. **Init:** goal recovered from memory (0064 note). Claim dry-run → shared-checkout refusal → worktree isolation → claimed **0065** (`26b2212`). Anchor gate: ELIGIBLE (R2a). Baseline verified green.
2. **Execution model:** superpowers:subagent-driven-development — fresh implementer subagent per task, task-scoped reviewer (spec + quality verdicts), fix subagents on findings, re-review loops; durable ledger `.superpowers/sdd/progress.md`; final whole-branch review at the end. Models tiered (sonnet implementers; opus for risky-diff reviews; fable for the final review).
3. **Task 1 — CoeditingRegistry (INV-10 gate):** `2597cba`; review found untested `syncContext` context-key derivation (plan-mandated gap) → fix `de83757` (11 unit tests). Approved.
4. **Task 2 — baseline router (INV-7):** `447a117`. Deviations approved by review: `.git/logs/HEAD` reflog watcher nudging `repo.status()` (vscode.git's watcher missed external commits in the host; change detection still keyed off `state.HEAD.commit`), E2E suites gaining enter-calls early. Machine-landing advance deleted; `markReviewed` replaces `pinDiffBaseline`. Approved.
5. **Task 3 — native diff surface:** `cf65528` (QuickDiff, `cowriting-baseline:` provider, Review Changes, status bar, pure `countLineHunks`). Approved.
6. **Task 4 — gate every surface:** `a323b82`; review confirmed a PUC-7 restore-on-enter gap (proposals/attribution) → fix `a36353d` (registry subscribers + regression E2E verified fail-without-fix). Approved.
7. **Plan-file repair:** fused `---### Task 5` heading broke the task extractor → `f23fc4a`.
8. **Task 5 — EditFlow extraction:** `ad5d34b`; review caught a real regression (palette accept/reject-all no longer repainted an open review panel) → fix `8fbbe45`. Approved.
9. **Task 6 — comments-first ask loop:** `75f4a3c`; review found a **Critical**: `cowriting.edit`'s no-selection route (⌘⌥E) dead-ended in the rejecting input stub → fix `c9975ba` (all ask entry points converge on `ThreadController.askClaude`; machine-author guard; offer-WeakMap cleanup). Approved. `editInstructionInput.ts` deleted.
10. **Task 7 — built-in preview annotations:** `17fc01e` (pure markdown-it plugin; sentinel machinery generalized 3-way, backward-compatible — 84/84 old tests unedited; render-wrapper approach correctly replacing the brief's per-token rule; mermaid rendering via `markdown.previewScripts` — Q4 confirmed). Approved; intra-diagram diff scoped out by the implementer.
11. **Task 8 — webview sunset:** `2170a0d` (1472 lines; coverage moved in-commit, arithmetic reconciled exactly). Review flagged the Task-7 scope-out as an unsanctioned INV-29 retirement + `renderReview` going production-dead. **Adjudication:** plan T7 §2.6 mandated the re-emit and its stop condition never triggered → fix `705de31` (diffMermaid wired into the fence path, 6 ported unit tests) + hardening `ff42b5a` (body-matched queue; misalignment degrades to verbatim; parse-less re-render reset). `renderReview` kept deliberately (see Deferred decisions). Approved.
12. **Task 9 — full-loop E2E + smoke:** `22c9da0` (PUC-7→8→2→1 walk; `scripts/smoke-native-loop.mjs` + `npm run smoke:native`, operator-run). Review verified the implementer's adjudication (interior tweak → anchor orphans by contract; Keep-by-id survives) and surfaced a **pre-existing INV-5 gap** (reject-after-interior-edit) → plan text corrected (`17366a4`) + **issue #70 filed** (bug P2).
13. **Final whole-branch review (fable):** verdict "with fixes" — Critical (preview-annotation host leaked past INV-10: wrong-doc annotation + stale-after-exit), Important (thread contextValue conflation made Resolve/Reopen permanently unmatchable once Claude replied; coedited docs opened post-activation never got a baseline) → ONE fix wave `0d69a29` (all three at root + regression E2Es + fold-in minors + polish). Re-review: full when-clause state matrix verified, no double-establish race, **Ready to merge: Yes**. **Issue #71 filed** (surface-polish batch).
14. **Ship:** final verification (typecheck, 301 unit, 90+5 E2E, build — all green) → pushed → **PR #72****squash-merged to main `935fcc3`** → remote branch deleted → plan re-archived to content repo (`b0687b6`).
## Deferred decisions
- **`renderReview` left in place though production-dead after the Task 8 webview sunset.** The old F10 pure render engine (`renderReview`/`renderOp` in `trackChangesModel.ts`) lost its last production consumer when `trackChangesPreview.ts` was deleted; three E2E suites (authorship, s48PinClean, undoMarks) now probe it directly as a pure module via a test helper. I kept it (tested pure module, INV-33 documentation value, low carrying cost) rather than deleting + reworking those suites mid-migration. Operator may prefer deletion as a follow-up cleanup.
- **INV-29 mermaid-diff parity adjudicated as plan-mandated, not retired.** Task 7 scoped out the F7.1 re-emit for testability; the plan's stop condition (previewScripts unworkable) never triggered, so I dispatched a fix to wire `mermaidDiff` into the preview fence path rather than accepting the silent retirement. Judged from plan T7 §2.6 + the plan's spec-coverage self-check.
## Cut state
- Plugin `main` = `935fcc3` (PR #72 squash). Working tree clean. Remote `session-0065` deleted; **local branch ref `session-0065` remains** (squash → plain `-d` refuses; operator has preferred manual cleanup of such refs).
- Content repo `main` = `b0687b6` (plan re-archived with in-execution edits: heading fix + Task 9 wording correction).
- Suites at HEAD: typecheck · 301 unit · 90 + 5 host E2E (1 env-conditional #54 undo-probe pending on some runs) · esbuild — all green.
- Issues: **#70 filed** (INV-5 reject-after-interior-edit, bug P2), **#71 filed** (surface polish, task P3); #69 (D15) pre-existing.
- Sessions 0061/0062/0063 still INPROGRESS (other Claude sessions); their checkouts/branches untouched.
## Operator plate
1. **`npm run smoke:native`** — rung-3 real-SDK smoke through the comment loop (one green run completes the plan's definition of done; report it to this transcript's home if you want the record complete).
2. **Mermaid-preview GUI smoke** — open a coedited doc with a changed ```mermaid fence in the built-in preview; confirm diagram renders with cwAdded/ghost styling + legend (previewScripts path isn't E2E-queryable).
3. Optional cleanup: `git branch -D session-0065` in the plugin clone; delete the now-merged worktree leftovers if any; #71 when convenient.
## Next-session prompt
```
/goal Plan and execute #70 — fix the INV-5 reject-after-interior-edit gap (proposalController revertInPlace orphan path); fix directions in the issue body
```
@@ -0,0 +1,113 @@
# Session 0066.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-07-02T16-24 (PST)
> End: 2026-07-02T18-54 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 14c9f9cc-92a1-47ef-a12d-9b0ac2469e13
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin/.claude/worktrees/fix-70-inv5-reject-gap
> Status: FINALIZED
## Launch prompt
```
/goal Plan and execute #70 — fix the INV-5 reject-after-interior-edit gap (proposalController revertInPlace orphan path); fix directions in the issue body
```
## Plan
> Anchor: #70 (type/bug, P2 — ELIGIBLE R2b)
Fix the INV-5 reject-after-interior-edit gap: `revertInPlace` in
`src/proposalController.ts` skipped the restore when an interior edit inside
the pending proposal's range orphaned the exact-substring anchor (INV-11),
yet removed the proposal and reported success — ✗ Reject silently left the
(edited) proposed text in the buffer. Evaluate the issue's fix directions
(a/b/c), TDD the fix, ship branch → PR → merge per §5.4.
## Pre-state
- `origin/main` at `d8f2d73` (session 0065's native-surfaces migration
shipped; #70/#71 filed from its final review).
- Three concurrent sessions in flight (0061, 0062, 0063 placeholders);
session 0062 occupied the canonical checkout, so the #134 claim guard
refused a shared-tree claim — this session created worktree
`.claude/worktrees/fix-70-inv5-reject-gap` off `origin/main` FIRST and
claimed 0066 from inside it (the inverted order the init documents).
- Canonical checkout's untracked files (`specs/`, a plan doc) belong to the
concurrent session and were left untouched.
## Arc
1. **Init.** Claimed 0066 from the worktree; #70 checked ELIGIBLE (type/bug,
R2b; needed `--host git.benstull.org` + the issues token service). Fetched
the issue body carrying the three fix directions.
2. **Design.** Explored `proposalController.ts` / `anchorer.ts` /
`editorProposalController.ts`. Chose direction **(a)** — tracked applied
range — with **(b)** honest hard failure as fallback; rejected **(c)**
fuzzy resolve (INV-11: never guess). Key insight: `state.live` is
shift-maintained but clobbered to a collapsed placeholder by any
intervening `renderAll`, so a dedicated `appliedSpans` map with explicit
trust semantics was designed instead.
3. **Plan.** `docs/superpowers/plans/2026-07-02-70-inv5-reject-after-interior-edit.md`
(4 TDD tasks). Fused single-session path (leaf bug), executed inline.
4. **Tasks 14** (commits `a9f8dd0`, `82cea74`, `ddd634b`, `f47dfbc`):
appliedSpans + revertInPlace fallback (E2E repro failed-then-passed);
hard-failure lock test (first cut used a whole-buffer rewrite and FAILED —
VS Code minimizes workspace edits, so it decomposed into interior hunks
the span legitimately survives; switched to a boundary-straddling
deletion); rejectAll `{reverted, skipped}` parity; fullLoop reject leg now
tweaks inside the pending range first (retiring its deliberate avoidance).
5. **Review wave** (high-effort multi-agent workflow; 10 CONFIRMED findings,
9 fixed in `e389406`): pure `shiftTracked` in anchorer (+11 unit tests;
insertion-at-span-start lands BEFORE the span), tracked spans cleared on
doc close (disk can change with no events), rebuild-only resync (duplicate
-text resolve can't clobber continuous tracking), `guard.isReadOnly` on
finalize/revert (INV-16), "Discard proposal (leave text)" action on the
hard-fail warning (reload leaves no other dismissal path), CodeLens pair
anchored at the tracked span (the fixed path stays reachable from the
primary gesture), QuickPick batch menu routed through the reporting
commands, one `clearProposal` helper (fixes `reject()`'s stale bookkeeping).
The 10th finding is pre-existing → filed **#74**.
6. **Ship.** PR #73 → squash-merged to `main` as `7583165`; #70 auto-closed.
312 unit + 94/5 host E2E green (1 occasionally pending = the known #54
undo-capability probe). Plan archived to the content repo
(`plans/2026-07-02-70-inv5-reject-after-interior-edit.md` at `0019d58`).
Remote branch `s70-inv5-reject-gap` deleted after merge.
## Deployment pipeline (§9)
This app is a VS Code extension with no deploy infra (no flotilla/PPE stage
exists — per prior sessions, "no deploy pipeline (extension, no flotilla)").
The localhost stage ran in full: typecheck + 312 unit + 94/5 host E2E green.
Nothing to deploy; no release tag applies.
## Cut state
- `origin/main` = `7583165` (the #70 fix). Worktree branch merged; remote
branch deleted; worktree torn down at finalize.
- Issue #70 CLOSED (auto, via PR). Issue #74 (bug P3) OPEN — filed this
session: `resolve()` accepts a single exact occurrence without a context
check (pre-existing; duplicated block can hijack any anchor consumer).
- Concurrent sessions 0061/0062/0063 still INPROGRESS — not mine to adopt
(live, per their stamps).
## Operator plate
- **Manual smoke owed:** the "Discard proposal (leave text)" warning button
(native notification — not E2E-testable) + the smokes already owed from
sessions 0062/0065.
- Prod promotion: N/A (no deploy pipeline for the extension).
## Deferred decisions
No low-confidence calls this session. (Design forks — (a)+(b) over (c), the
distrust semantics, squash-merge — all followed the issue's own directions,
existing invariants, or repo convention.)
## Next-session prompt
```
/goal Plan and execute #71 — surface-polish batch from the native-surfaces final review (6 small items listed in the issue body)
```
@@ -0,0 +1,109 @@
# Session 0067.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-07-02T20-07 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: d29a9976-cb2d-427b-9ef1-4488b3c2a262
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin/.claude/worktrees/s71-surface-polish
> End: 2026-07-03T07-39 (PST)
> Status: **FINALIZED**
## Launch prompt
```
Plan and execute #71 — surface-polish batch from the native-surfaces final review (6 small items listed in the issue body)
```
## Plan
> Anchor: #71 (type/task, P3 — ELIGIBLE R2b)
Plan and execute #71 — the surface-polish batch from the native-surfaces
migration final review (session 0065). Six small items from the issue body:
1. `markReviewed`/`pin()` misleading warning on never-established docs (`src/diffViewController.ts`)
2. Delete orphaned `cowriting.acceptProposal`/`rejectProposal` declarations (`package.json`)
3. `openReviewPreview` editor/title when-key: `editorLangId``resourceLangId`
4. README F3 section drift: SUPERSEDED banner + stale chord references
5. Gate/warn `cowriting.createThread` palette entry when not coediting
6. Clear `gitBaseline` reflog debounce timer in `dispose()` (`src/gitBaseline.ts`)
Fused session (trivial leaf batch): just-in-time plan → execute → tests green
→ PR → merge. Isolated worktree `s71-surface-polish` (checkout contended by
live session 0062).
## Execution record
- Plan written (`docs/superpowers/plans/2026-07-02-71-surface-polish.md`, committed
on the branch) and executed inline — all six items landed as one commit each:
1. `pin()` not-coediting branch → "Run ✦ Coedit this Document with Claude first."
2. orphaned `acceptProposal`/`rejectProposal` declarations + `when:false` palette
entries deleted
3. `openReviewPreview` editor/title when-key `editorLangId``resourceLangId`
(+ the E2E assertion at `reviewPanelMenu.test.ts:129` that codified the old
key — the one mid-run E2E failure, fixed forward)
4. README: F3 superseded-commands banner; F7/F10 banner chord notes
5. `createThread` palette entry gated (same when as its editor/context entry)
6. `gitBaseline` reflog debounce timer cleared on dispose
- Verification: 312 unit + typecheck + build + full host E2E green.
- Shipped: PR #75 squash-merged to `main` as `3e7ad58`; issue #71 auto-closed;
remote branch deleted. No flotilla/PPE pipeline — this app is a VS Code
extension with no deployed service (§9 ends at merge + green suites here).
## Post-ship arc (operator review + Q&A)
- Launched the EDH from the worktree for operator review
(`code --extensionDevelopmentPath=<worktree> <worktree>/sandbox`; VS Code
found at `~/Downloads/Visual Studio Code.app`).
- Operator Q&A, all assessment-only: (1) comment box is the native Comments
API widget — Cmd+Enter submits, Enter newlines; operator chose to keep the
native mapping. (2) macOS "access data from other apps" TCC prompt — App
Data Protection category, distinct from #59; VS Code found QUARANTINED +
App-Translocated (runs from ~/Downloads) — recommended move to
/Applications; #59 env guard confirmed present (`liveTurn.ts:82-83`).
(3) External (outside-editor) Claude edits: detected via the disk-sync
branch (`attributionController.ts:175`), rendered neutral, never attributed
— cross-rung sidecar is the designed path to attributed external edits.
(4) `onDidChangeTextDocument` carries no originator identity (why the seam
pre-registers edits, INV-9). (5) Sketched per-author diff surfaces
(synthetic per-author baselines + second QuickDiffProvider). (6) Explained
tweak-before-accept tracking (mixed authorship; #70 appliedSpans; reject
reverts whole span or refuses).
- The Q&A converged on a product pivot the operator dictated: simplify review
to baseline-diff Keep/Reject (three-verb toolbar, no annotations, no
preview). Handed off to capture session 0068 (draft written, NOT filed) and
an implementation prompt.
## Deferred decisions
- design-readiness soft gate returned MISSING for #71 — proceeded: all six
items are copy/menu-gating/cleanup within existing surfaces, no new UI
beyond the design system (R4 not implicated).
## Closeout
Shipped: PR #75 (squash `3e7ad58`), issue #71 closed, remote branch deleted.
Suites: 312 unit + typecheck + build + full host E2E green (one mid-run E2E
fix: `reviewPanelMenu.test.ts:129` had codified the wrong when-key). Plan
archived to content repo `plans/2026-07-02-71-surface-polish.md`. No
flotilla/PPE stage exists for this app (VS Code extension, no deployed
service) — §9 ends at merge + green suites. Worktree `s71-surface-polish` is
merged-clean but KEPT at finalize: the operator's EDH review window still runs
from it (`--extensionDevelopmentPath`); remove after closing that window
(`git worktree remove .claude/worktrees/s71-surface-polish`). Operator plate:
close the EDH then remove the worktree; move VS Code.app out of ~/Downloads to
/Applications (translocation/TCC); decide on the 0068 unfiled draft.
## Next-session prompt
/goal Resume the capture: file the unfiled draft
issues/simplify-review-baseline-diff-keep-reject.md as type/feature P1 on
benstull/vscode-cowriting-plugin, then brainstorm its Solution Design
(specs/coauthoring-baseline-diff-review.md) on the new issue, locking the five
forks in the draft's Solution notes.
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
@@ -0,0 +1,50 @@
# Session 0068.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-07-03T06-53 (PST)
> Type: capture
> Posture: careful
> Claude-Session: d29a9976-cb2d-427b-9ef1-4488b3c2a262
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin/.claude/worktrees/s71-surface-polish
> End: 2026-07-03T07-39 (PST)
> Status: **FINALIZED — tracked-lite (capture); NOTHING FILED**
## Launch prompt
```
Capture one type/feature issue: simplify the review model to baseline-diff Keep/Reject — comments to ask Claude, native diff to review (per-hunk Keep/Reject/Modify, Keep advances the baseline), no annotations, no preview surface; toolbar reduces to Ask Claude / Review Changes / Mark All Changes as Reviewed. Context developed in conversation following session 0067; brainstorming session to follow on the filed issue.
```
## Arc
- Claimed 0068 (capture, careful) from the s71 worktree; resolved content repo
`benstull/vscode-cowriting-plugin-content`. Detector listed ~24 pre-#80
unfiled-marker drafts for long-filed issues (operator-owned backlog hygiene
— untouched).
- Drafted `issues/simplify-review-baseline-diff-keep-reject.md` per the
feature batching profile: business block → operator approved ("Looks
good") → structural block + solution notes (approach pre-validated in the
originating 0067 conversation; five design forks recorded).
- **Filing withheld (INV-1):** at the file-on-approval gate the operator
pivoted — "Just give me the prompt for implementing" — so NO issue was
filed. Delivered the implementation prompt (unattended design→build chain
with named yolo override, plus the two-step interactive variant), with `#N`
pending the eventual filing.
- Finalize: draft left in the content-repo working tree as the resumable
surface (never auto-discarded); type-fit judge not run (nothing filed);
type-fit: judged 0, mismatch 0, overridden 0.
## Deferred decisions
_None — careful posture; every gate was operator-answered. The one open
operator decision (file the draft or not) is deliberately left open and
carried in the next-session prompt._
## Next-session prompt
/goal Resume the capture: file the unfiled draft
issues/simplify-review-baseline-diff-keep-reject.md as type/feature P1 on
benstull/vscode-cowriting-plugin, then brainstorm its Solution Design
(specs/coauthoring-baseline-diff-review.md) on the new issue, locking the five
forks in the draft's Solution notes.
+12
View File
@@ -190,5 +190,17 @@
},
"0064": {
"title": ""
},
"0065": {
"title": ""
},
"0066": {
"title": ""
},
"0067": {
"title": ""
},
"0068": {
"title": ""
}
}
+24
View File
@@ -101,6 +101,30 @@ export function resolve(docText: string, fp: Fingerprint): OffsetRange | "orphan
return "orphaned";
}
/**
* #70 (INV-5/INV-11): maintain a TRACKED applied span across an in-session edit.
* Unlike `shift`, which clamps every point into a best-effort range, a tracked
* span must stay *provably* meaningful — it is a revert target. So:
* - an edit fully OUTSIDE or fully INSIDE the span shifts it (the span is
* still "the applied block, as tweaked");
* - an insertion exactly AT the span start lands BEFORE the span (both ends
* shift right) — matching exact-resolve semantics, where text typed just
* before the applied block is never part of it;
* - any edit STRADDLING a span boundary returns "distrusted": the shifted
* result would be a clamped guess, and stale text is never reverted by
* guess (INV-11).
*/
export function shiftTracked(range: OffsetRange, edit: TextEdit): OffsetRange | "distrusted" {
const delta = edit.newLength - (edit.end - edit.start);
if (edit.start === edit.end && edit.start === range.start) {
return { start: range.start + delta, end: range.end + delta };
}
const outside = edit.end <= range.start || edit.start >= range.end;
const inside = edit.start >= range.start && edit.end <= range.end;
if (!outside && !inside) return "distrusted";
return shift(range, edit);
}
/** Maintain a live range across an in-session edit (spec §6.4 `shift`). */
export function shift(range: OffsetRange, edit: TextEdit): OffsetRange {
const delta = edit.newLength - (edit.end - edit.start);
+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();
}
}
+132 -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,72 @@ 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);
const mode = this.modes.get(key);
if (mode === undefined) {
// never established — the document is not being coedited (#71 item 1)
void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first.");
return;
}
if (mode !== "snapshot") {
void vscode.window.showWarningMessage(
"Cowriting: this document is git-tracked — commit to advance the baseline.",
);
return;
}
this.capture(document, "pinned");
}
@@ -103,12 +166,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 +211,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>`;
}
+47 -9
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,40 @@ 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 lenses: vscode.CodeLens[] = [];
// #70: an interior tweak orphans the exact anchor (anchorStart null) but the
// proposal stays fully decidable — Keep by id, Reject via the tracked span —
// so the lens pair anchors at the tracked span instead of vanishing (which
// made the fixed reject path unreachable from the primary gesture surface).
const applied: { id: string; at: number }[] = [];
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 (!this.proposals.isApplied(key, v.id)) continue;
const at = v.anchorStart ?? this.proposals.trackedSpan(key, v.id)?.start;
if (at !== undefined) applied.push({ id: v.id, at });
}
const lenses: vscode.CodeLens[] = [];
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.at);
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;
@@ -232,10 +268,12 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
if (!pick) return;
const all = pick.includes("ALL");
if (kind === "accept") {
if (all) await this.proposals.acceptAllProposals(doc);
// The batch commands own the applied/skipped reporting (#70: a silent:true
// batch with a discarded tally re-creates the silent-skip failure mode).
if (all) await vscode.commands.executeCommand("cowriting.acceptAllProposals");
else await this.proposals.finalizeInPlace(key, id);
} else {
if (all) await this.proposals.rejectAll(doc);
if (all) await vscode.commands.executeCommand("cowriting.rejectAllProposals");
else await this.proposals.revertInPlace(key, id);
}
}
+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, skipped } = await proposalController.rejectAll(doc);
if (reverted === 0 && skipped === 0) return;
const skipNote = skipped > 0 ? `, ${skipped} skipped (applied text not locatable — undo your edits or Keep)` : "";
void vscode.window.showInformationMessage(
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}${skipNote}.`,
);
}),
);
// --- 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),
};
}
+136
View File
@@ -0,0 +1,136 @@
/**
* 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();
if (pending) clearTimeout(pending);
},
});
} 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+$/, "");
}
+152 -31
View File
@@ -13,12 +13,13 @@
import * as vscode from "vscode";
import { SidecarRouter, docIdentity } from "./sidecarRouter";
import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model";
import { resolve, shift, buildFingerprint, type OffsetRange } from "./anchorer";
import { resolve, shift, shiftTracked, buildFingerprint, type OffsetRange } from "./anchorer";
import { addProposal, removeProposal, setProposalApplied } from "./proposalModel";
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 {
@@ -41,6 +42,16 @@ interface DocState {
unresolved: Set<string>;
/** ids already optimistically applied to the buffer (so the trigger doesn't re-apply). */
applied: Set<string>;
/**
* #70 (INV-5): the F12 applied-range bookkeeping proposal id -> the applied
* span's CURRENT buffer range. Written at optimistic-apply, re-synced whenever
* the exact anchor resolves (renderAll), shifted through interior-safe edits,
* and DELETED (distrusted) when an edit straddles a span boundary a clamped
* range is a guess, and stale text is never applied by guess (INV-11). Lets
* Reject restore the original after the human types INSIDE the pending range
* (which orphans the exact-substring anchor).
*/
appliedSpans: Map<string, OffsetRange>;
}
export class ProposalController implements vscode.Disposable {
@@ -56,11 +67,43 @@ 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)),
// #70: a tracked applied span is only meaningful while its buffer is live —
// a closed doc can change on disk with no change events, so a span kept
// across close/reopen could revert over unrelated text (INV-11: never by
// guess). Cleared here; a resolving anchor rebuilds it at the next renderAll.
vscode.workspace.onDidCloseTextDocument((doc) => {
this.docs.get(this.keyOf(doc))?.appliedSpans.clear();
}),
// 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);
}),
);
}
@@ -109,6 +152,7 @@ export class ProposalController implements vscode.Disposable {
live: new Map(),
unresolved: new Set(),
applied: new Set(),
appliedSpans: new Map(),
};
this.docs.set(docPath, state);
}
@@ -298,6 +342,22 @@ export class ProposalController implements vscode.Disposable {
return this.docs.get(docPath)?.applied.has(proposalId) ?? false;
}
/** #70: the shift-tracked applied span (the revert-fallback bookkeeping)
* exposed so the editor surface can keep the decision gestures reachable
* for an applied proposal whose exact anchor an interior tweak orphaned. */
trackedSpan(docPath: string, proposalId: string): OffsetRange | undefined {
return this.docs.get(docPath)?.appliedSpans.get(proposalId);
}
/** The one proposal-clear sequence (record + in-memory bookkeeping + re-render)
* shared by finalize/revert/reject so no path leaves a stale entry behind. */
private clearProposal(state: DocState, proposalId: string, document?: vscode.TextDocument): void {
this.store.update(state.docPath, (a) => removeProposal(a, proposalId));
state.applied.delete(proposalId);
state.appliedSpans.delete(proposalId);
if (document) this.renderAll(document);
}
/**
* F12/#64 (INV-48): optimistically apply a pending proposal INTO the buffer so
* the editor shows the would-be-accepted result (editable). Reuses the F4
@@ -348,6 +408,7 @@ export class ProposalController implements vscode.Disposable {
// end shifts by the net length delta of the replacement).
const appliedStart = resolved.start;
const appliedEnd = appliedStart + proposal.replacement.length;
state.appliedSpans.set(proposalId, { start: appliedStart, end: appliedEnd });
const appliedFp = buildFingerprint(document.getText(), { start: appliedStart, end: appliedEnd });
this.store.update(docPath, (a) => setProposalApplied(a, proposalId, appliedFp, original));
this.renderAll(document);
@@ -380,55 +441,88 @@ 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> {
if (this.guard.isReadOnly(docPath)) return false;
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
const document = this.openDoc(hit.state);
if (!document) return false;
this.attribution.signalLanded(document);
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
this.renderAll(document);
this.clearProposal(hit.state, proposalId, document);
return true;
}
/**
* F12/#64 (INV-51): REJECT an optimistically-applied proposal replace its live
* applied span with the stored `original`, then clear it. Reverts the whole block
* regardless of any in-place edits the human made to the inserted text.
* regardless of any in-place edits the human made to the inserted text: when an
* interior tweak has orphaned the exact anchor (INV-11), the revert falls back to
* the shift-tracked applied span (#70, INV-5). When neither locates the span
* (e.g. a boundary-straddling external rewrite distrusted it), the reject FAILS
* loudly warning shown, proposal kept, `false` returned instead of silently
* leaving the proposed text in the buffer. Undo (or Keep) remains the way out.
*/
async revertInPlace(docPath: string, proposalId: string): Promise<boolean> {
async revertInPlace(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
if (this.guard.isReadOnly(docPath)) return false;
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
const document = this.openDoc(hit.state);
if (!document) return false;
// Never optimistically applied → nothing of ours is in the buffer; clearing
// the record IS the reject (the legacy pending-only path).
if (hit.proposal.original === undefined) {
this.clearProposal(hit.state, proposalId, document);
return true;
}
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
if (resolved !== "orphaned" && hit.proposal.original !== undefined) {
const we = new vscode.WorkspaceEdit();
we.replace(
document.uri,
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
hit.proposal.original,
);
if (!(await vscode.workspace.applyEdit(we))) return false;
const span = resolved !== "orphaned" ? resolved : hit.state.appliedSpans.get(proposalId);
if (!span) {
if (!opts?.silent) {
// The revert is refused, but the record must stay dismissable (a window
// reload empties both the tracked spans and the undo stack): offer a
// record-only discard that leaves the buffer exactly as it stands.
const DISCARD = "Discard proposal (leave text)";
void vscode.window
.showWarningMessage(
"Cowriting: this proposal's applied text can't be located (it changed or moved) — it is never reverted by guess. Undo your edits, Keep it, or discard the record as-is.",
DISCARD,
)
.then((choice) => {
if (choice === DISCARD) this.clearProposal(hit.state, proposalId, document);
});
}
return false;
}
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
this.renderAll(document);
const we = new vscode.WorkspaceEdit();
we.replace(
document.uri,
new vscode.Range(document.positionAt(span.start), document.positionAt(span.end)),
hit.proposal.original,
);
if (!(await vscode.workspace.applyEdit(we))) return false;
this.clearProposal(hit.state, proposalId, document);
return true;
}
/**
* F12/#64 (INV-53): reject EVERY pending proposal on a document revert each in
* DESCENDING anchor order (so an earlier revert never shifts a later one's
* offsets), symmetric with #46's accept-all. Returns the reverted count.
* DESCENDING span order (so an earlier revert never shifts a later one's
* offsets), symmetric with #46's accept-all. #70: a tweaked proposal whose exact
* anchor is orphaned orders (and reverts) by its shift-tracked applied span;
* one with no locatable span is SKIPPED counted, never guessed (INV-11)
* and the per-proposal warning is suppressed in favor of the batch report.
*/
async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number }> {
if (!this.isTracked(document)) return { reverted: 0 };
async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number; skipped: number }> {
if (!this.isTracked(document)) return { reverted: 0, skipped: 0 };
const docPath = this.keyOf(document);
const state = this.ensureState(document);
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
@@ -437,24 +531,33 @@ export class ProposalController implements vscode.Disposable {
.map((p) => {
const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
const r = fp ? resolve(text, fp) : "orphaned";
return { id: p.id, start: r === "orphaned" ? -1 : r.start };
const start = r !== "orphaned" ? r.start : state.appliedSpans.get(p.id)?.start ?? -1;
return { id: p.id, start };
})
.sort((a, b) => b.start - a.start);
let reverted = 0;
for (const it of ordered) if (await this.revertInPlace(docPath, it.id)) reverted++;
return { reverted };
let skipped = 0;
for (const it of ordered) {
if (await this.revertInPlace(docPath, it.id, { silent: true })) reverted++;
else skipped++;
}
return { reverted, skipped };
}
private reject(state: DocState, proposal: Proposal): void {
if (this.guard.isReadOnly(state.docPath)) return;
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
const document = this.openDoc(state);
if (document) this.renderAll(document);
this.clearProposal(state, proposal.id, this.openDoc(state));
}
// ---- 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 +565,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;
@@ -472,6 +576,14 @@ export class ProposalController implements vscode.Disposable {
this.recordProposal(state, proposal, { start: off, end: off }, false);
} else {
this.recordProposal(state, proposal, resolved, true);
// #70: REBUILD the applied-range bookkeeping from an exact resolve when
// the map has no entry (a prior-session apply, or after a close/reopen
// clear). An entry already present has been shift-maintained continuously
// since apply and is ground truth — never clobbered by a resolve, which
// can land on an exact duplicate of the applied text elsewhere.
if (state.applied.has(proposal.id) && !state.appliedSpans.has(proposal.id)) {
state.appliedSpans.set(proposal.id, resolved);
}
}
}
this.renderStatus(state);
@@ -490,10 +602,19 @@ export class ProposalController implements vscode.Disposable {
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
const state = this.docs.get(this.keyOf(e.document));
if (!state || state.live.size === 0) return;
if (!state || (state.live.size === 0 && state.appliedSpans.size === 0)) return;
for (const change of e.contentChanges) {
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
for (const [id, range] of state.live) state.live.set(id, shift(range, edit));
// #70: an edit fully inside a tracked applied span (or fully outside it)
// keeps the span meaningful; one straddling a boundary — e.g. a whole-buffer
// external replace — makes the shifted range a clamped guess, so the span
// is distrusted (deleted) rather than reverted-to by guess (INV-11).
for (const [id, range] of state.appliedSpans) {
const shifted = shiftTracked(range, edit);
if (shifted === "distrusted") state.appliedSpans.delete(id);
else state.appliedSpans.set(id, shifted);
}
}
}
+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 webviewhost 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 messageseam 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();
}
}
+52 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { buildFingerprint, resolve, shift, type OffsetRange } from "../src/anchorer";
import { buildFingerprint, resolve, shift, shiftTracked, type OffsetRange } from "../src/anchorer";
const DOC = "line0\nline1 needle here\nline2\nneedle again on line3\n";
@@ -67,3 +67,54 @@ describe("shift", () => {
expect(shift(r, { start: 3, end: 7, newLength: 1 })).toEqual({ start: 3, end: 7 });
});
});
describe("shiftTracked (#70 — tracked applied spans)", () => {
const r: OffsetRange = { start: 10, end: 20 };
it("edit entirely before the span shifts both endpoints by the delta", () => {
expect(shiftTracked(r, { start: 0, end: 0, newLength: 3 })).toEqual({ start: 13, end: 23 });
});
it("edit entirely after the span leaves it unchanged", () => {
expect(shiftTracked(r, { start: 25, end: 28, newLength: 0 })).toEqual({ start: 10, end: 20 });
});
it("edit fully inside the span keeps the start and grows/shrinks the end", () => {
// replace [12,15) with 5 chars (delta = +2)
expect(shiftTracked(r, { start: 12, end: 15, newLength: 5 })).toEqual({ start: 10, end: 22 });
});
it("replacing the span's exact full contents is an interior edit", () => {
expect(shiftTracked(r, { start: 10, end: 20, newLength: 4 })).toEqual({ start: 10, end: 14 });
});
it("an insertion exactly at the span start lands BEFORE the span (never absorbed)", () => {
expect(shiftTracked(r, { start: 10, end: 10, newLength: 6 })).toEqual({ start: 16, end: 26 });
});
it("an insertion exactly at the span end lands AFTER the span (never absorbed)", () => {
expect(shiftTracked(r, { start: 20, end: 20, newLength: 6 })).toEqual({ start: 10, end: 20 });
});
it("an insertion at an EMPTY span's point lands before it (deletion-proposal span)", () => {
const empty: OffsetRange = { start: 10, end: 10 };
expect(shiftTracked(empty, { start: 10, end: 10, newLength: 3 })).toEqual({ start: 13, end: 13 });
});
it("an edit straddling the span's start boundary is distrusted (INV-11)", () => {
expect(shiftTracked(r, { start: 8, end: 12, newLength: 0 })).toBe("distrusted");
});
it("an edit straddling the span's end boundary is distrusted (INV-11)", () => {
expect(shiftTracked(r, { start: 18, end: 22, newLength: 1 })).toBe("distrusted");
});
it("an edit fully containing the span (e.g. a wholesale replace) is distrusted (INV-11)", () => {
expect(shiftTracked(r, { start: 0, end: 30, newLength: 12 })).toBe("distrusted");
});
it("a deletion consuming an EMPTY span's neighborhood is distrusted (INV-11)", () => {
const empty: OffsetRange = { start: 10, end: 10 };
expect(shiftTracked(empty, { start: 8, end: 12, newLength: 0 })).toBe("distrusted");
});
});
+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);
+177 -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);
@@ -200,6 +204,153 @@ suite("F12 inline diff — control parity (#64, INV-53)", () => {
});
});
// #70 (INV-5): rejecting a proposal AFTER typing inside its pending range must
// still restore the retained original. The exact-substring anchor is orphaned by
// the interior tweak (INV-1/INV-11) — the revert falls back to the F12
// applied-range bookkeeping (appliedSpans), which shift-tracks the span through
// interior edits.
suite("F12 inline diff — #70 reject after interior edit (INV-5)", () => {
test("revertInPlace restores the original after a tweak inside the pending range", async () => {
const ORIGINAL = "The quick brown fox jumps.";
const { doc } = await freshDoc("docs/s70-reject-tweaked.md", `# T\n\n${ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const docKey = p.keyFor(doc);
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "The rapid brown fox jumps.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
assert.ok(doc.getText().includes("The rapid brown fox jumps."), "optimistically applied");
// Human types INSIDE the pending range → orphans the exact anchor (INV-11).
const at = doc.getText().indexOf("rapid");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + "rapid".length)), "RAPID-ish");
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
await settle();
assert.strictEqual(p.listProposals(doc).find((v) => v.id === id)!.anchorStart, null, "anchor orphaned by the tweak");
// ✗ Reject: must restore the retained original EXACTLY (INV-5), not skip.
assert.ok(await p.revertInPlace(docKey, id!), "reject reports success");
await settle();
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
});
test("rejectByIdInPlace routes the tweaked-applied case the same way", async () => {
const ORIGINAL = "A steady closing line.";
const { doc } = await freshDoc("docs/s70-reject-route.md", `# T\n\n${ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const docKey = p.keyFor(doc);
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "A wobbly closing line.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
const at = doc.getText().indexOf("wobbly");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at)), "very-");
assert.ok(await vscode.workspace.applyEdit(we), "interior insertion applied");
await settle();
assert.ok(await p.rejectByIdInPlace(docKey, id!), "gesture-level reject succeeds");
await settle();
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
});
// Fix direction (b): when the tracked span itself is distrusted (an edit
// straddled its boundary — here a deletion running from the heading into the
// span's interior), the reject FAILS honestly: warning path, proposal kept,
// buffer untouched. Silent-skip-and-report-success (the #70 bug) must not come
// back; reverting at a clamped guessed offset (INV-11) must not either.
// (A boundary-straddling DELETION is used because VS Code minimizes workspace
// edits before change events fire — a wholesale rewrite sharing a prefix/suffix
// decomposes into interior hunks the span legitimately survives.)
test("reject fails loudly — proposal kept, buffer untouched — when the span is distrusted", async () => {
const ORIGINAL = "Sentence one stands here.";
const { doc } = await freshDoc("docs/s70-reject-distrust.md", `# T\n\n${ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const docKey = p.keyFor(doc);
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "Sentence ONE stands here.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
// Delete from inside the heading through the middle of the applied span:
// the edit straddles the span's start boundary → the tracked span is
// distrusted AND the exact anchor no longer resolves.
const mid = doc.getText().indexOf("ONE");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(2), doc.positionAt(mid + 1)), "");
assert.ok(await vscode.workspace.applyEdit(we), "boundary-straddling deletion applied");
await settle();
const before = doc.getText();
assert.strictEqual(await p.revertInPlace(docKey, id!), false, "reject reports failure (no silent success)");
await settle();
assert.strictEqual(doc.getText(), before, "buffer untouched — never reverted by guess (INV-11)");
assert.ok(p.listProposals(doc).some((v) => v.id === id), "proposal kept (not silently dropped)");
// Cleanup for later tests: the never-locatable husk is discarded via the
// plain record-only reject.
assert.strictEqual(p.rejectById(docKey, id!), true);
});
// rejectAll must revert a tweaked (orphaned-anchor) proposal via the same
// tracked-span fallback, ordered descending by that span so earlier reverts
// never shift later ones — and must count what it could not revert.
test("rejectAll reverts tweaked proposals via the tracked span and reports skips", async () => {
const { doc } = await freshDoc("docs/s70-rejall-tweak.md", "# T\n\nOne aaa.\n\nTwo bbb.\n");
const api = await getApi();
const p = api.proposalController;
const editFlow = api.editFlow;
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();
// Tweak INSIDE the first applied block → its exact anchor orphans.
const at = doc.getText().indexOf("AAA");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 3)), "AAAZ");
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
await settle();
const { reverted, skipped } = await p.rejectAll(doc);
await settle();
assert.strictEqual(reverted, ids.length, "ALL proposals reverted, tweaked one included");
assert.strictEqual(skipped, 0, "nothing skipped");
assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document fully restored (INV-5)");
assert.strictEqual(p.listProposals(doc).length, 0, "all cleared");
});
// The decision gestures must stay REACHABLE for a tweaked proposal: the ✓/✗
// CodeLens pair anchors at the tracked span when the interior tweak orphans
// the exact anchor — otherwise the fixed reject path exists only at the API.
test("the ✓ Keep / ✗ Reject CodeLens pair survives an interior tweak (tracked-span anchor)", async () => {
const ORIGINAL = "Lens target sentence here.";
const { doc } = await freshDoc("docs/s70-lens-tweak.md", `# T\n\n${ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "Lens TARGET sentence here.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
const at = doc.getText().indexOf("TARGET");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 6)), "TARGET-tweaked");
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
await settle();
assert.strictEqual(p.listProposals(doc).find((v) => v.id === id)!.anchorStart, null, "anchor orphaned");
const lenses = api.editorProposalController.provideCodeLenses(doc);
const titles = lenses.map((l) => l.command?.title);
assert.deepStrictEqual(titles, ["✓ Keep", "✗ Reject"], "the pair survives the tweak");
assert.deepStrictEqual(lenses[1].command!.arguments, [id], "reject lens still targets the tweaked proposal");
// The reject it routes at still restores the original (the Task-1 contract).
assert.ok(await p.revertInPlace(p.keyFor(doc), id!), "reject via the surviving gesture succeeds");
await settle();
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
});
});
// Reload-safety: a proposal that was optimistically applied in a PRIOR session
// persists with `original` set and its fp re-anchored to the applied text. A fresh
// controller (empty in-memory `applied` set) must NOT re-capture `original` from the
+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)");
});
});
+179
View File
@@ -0,0 +1,179 @@
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 second proposal in place --------------------------
// #70 (INV-5): the reject leg now ALSO takes an interior tweak first — the
// original deliberately rejected only an un-tweaked proposal because the
// pre-fix revert silently skipped an orphaned anchor. The tweak orphans the
// exact anchor; the revert must restore ORIG_REJECT via the tracked span.
const rejAt = doc.getText().indexOf(REJECT_REPLACEMENT);
assert.ok(rejAt >= 0, "reject proposal's applied text present");
const rejTweak = new vscode.WorkspaceEdit();
rejTweak.replace(doc.uri, new vscode.Range(doc.positionAt(rejAt + 2), doc.positionAt(rejAt + 2)), "x");
assert.ok(await vscode.workspace.applyEdit(rejTweak), "human tweak inside the reject proposal's range");
await settle();
assert.strictEqual(
api.proposalController.listProposals(doc).find((v) => v.id === rejectId)!.anchorStart,
null,
"tweak orphans the reject proposal's exact anchor (INV-11)",
);
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 ?? "", /resourceLangId == 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);
});
});