Compare commits
16 Commits
session-0064
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b95d24269 | |||
| 980cf9eefb | |||
| ff09c3b915 | |||
| 7a1b30a671 | |||
| 3e7ad58247 | |||
| a90cc06dfa | |||
| 2961378ede | |||
| a0a94d76a9 | |||
| 7583165354 | |||
| 42740f7cc6 | |||
| 6c3e331fc3 | |||
| d8f2d7383a | |||
| 935fcc35ee | |||
| 93eeaf13b8 | |||
| 26b2212efd | |||
| 0967067cb3 |
@@ -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 2–3 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 145–155 (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
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
@@ -1,118 +0,0 @@
|
||||
/* F7 track-changes preview — theme-aware via VS Code webview CSS variables. */
|
||||
body {
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editor-background);
|
||||
padding: 0 1.2rem 2rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#cw-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--vscode-editor-background);
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.85;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
/* Author-colored track changes — style = operation, color = author.
|
||||
underline = inserted, strikethrough = removed; human green/red, Claude blue/purple. */
|
||||
#cw-summary .cw-add { color: #3fb950; }
|
||||
#cw-summary .cw-del { color: #f85149; }
|
||||
.cw-blk { position: relative; }
|
||||
|
||||
/* base ins/del carry the operation; author classes carry the color */
|
||||
ins { text-decoration: none; }
|
||||
del { text-decoration: line-through; opacity: 0.7; }
|
||||
|
||||
.cw-ins-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
|
||||
.cw-ins-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
|
||||
.cw-ins-none { background: var(--vscode-diffEditor-insertedTextBackground, rgba(63,185,80,0.14)); text-decoration: none; }
|
||||
.cw-del-human { background: rgba(248,81,73,0.11); text-decoration: line-through; text-decoration-color: #f85149; }
|
||||
.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; }
|
||||
.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; }
|
||||
|
||||
/* Task 8 — overlap: if a future render path nests cw-ins-{A} inside cw-del-{B},
|
||||
both marks must show. cw-ins-* sets text-decoration:none which would otherwise
|
||||
suppress the parent del's strikethrough. `inherit` restores the parent's
|
||||
line-through while the child's border-bottom underline is unaffected.
|
||||
The engine currently emits SIBLING ins/del (never nested) so this rule is a
|
||||
forward defensive guarantee only. See task-8-report.md for details. */
|
||||
.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude { text-decoration: inherit; }
|
||||
|
||||
/* whole-block ops: an added block is an insertion (its author runs are emitted as
|
||||
cw-ins-* via colorByAuthor with kind="ins"); a standalone removed block is neutral
|
||||
struck (adjacency heuristic fallback) */
|
||||
.cw-added { background: transparent; }
|
||||
.cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.10)); text-decoration: line-through; opacity: 0.65; }
|
||||
.cw-changed { outline: none; }
|
||||
|
||||
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
|
||||
#cw-summary .cw-prop { opacity: 0.85; }
|
||||
.cw-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 0.7em;
|
||||
padding: 0 0.4em;
|
||||
border-radius: 3px;
|
||||
background: var(--vscode-badge-background);
|
||||
color: var(--vscode-badge-foreground);
|
||||
}
|
||||
.cw-error {
|
||||
border: 1px solid var(--vscode-inputValidation-errorBorder);
|
||||
background: var(--vscode-inputValidation-errorBackground);
|
||||
color: var(--vscode-errorForeground);
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
pre.mermaid { text-align: center; background: transparent; }
|
||||
pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
|
||||
|
||||
|
||||
/* F11 — preview toolbar buttons (Pin baseline; adaptive Ask Claude). Theme-aware. */
|
||||
#cw-header button {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
border: 1px solid var(--vscode-button-border, transparent);
|
||||
border-radius: 3px;
|
||||
padding: 0.1em 0.55em;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
#cw-header button:hover:not(:disabled) { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
#cw-header button:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* F10 interactive review — annotations toggle + ✓/✗ proposal blocks. */
|
||||
#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; }
|
||||
.cw-proposal {
|
||||
position: relative;
|
||||
border-left: 3px solid var(--vscode-panel-border, #555);
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent);
|
||||
padding: 0.4em 0.6em; margin: 0.4em 0; border-radius: 3px;
|
||||
}
|
||||
.cw-proposal-unanchored { border-left-style: dashed; opacity: 0.85; }
|
||||
.cw-actions { position: absolute; top: 0.2em; right: 0.4em; display: inline-flex; gap: 0.25em; }
|
||||
.cw-actions button {
|
||||
cursor: pointer; border: 1px solid var(--vscode-button-border, transparent);
|
||||
border-radius: 3px; font-size: 0.9em; line-height: 1; padding: 0.1em 0.35em;
|
||||
background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
|
||||
.cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
|
||||
.cw-btngroup { display: inline-flex; }
|
||||
.cw-btngroup .cw-caret { border-left: none; padding: 0.1em 0.25em; }
|
||||
.cw-actions .cw-accept { font-weight: 600; }
|
||||
.cw-accept:hover, .cw-btngroup:has(.cw-accept) .cw-caret:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
|
||||
.cw-reject:hover, .cw-btngroup:has(.cw-reject) .cw-caret:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
|
||||
|
||||
/* F7.1 (#22) intra-diagram mermaid diff legend. */
|
||||
.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
|
||||
.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; }
|
||||
.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; }
|
||||
.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; }
|
||||
.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; }
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* F7 preview webview client (sealed sandbox, INV-21). Receives annotated HTML
|
||||
* from the extension host and swaps it in; runs mermaid over `.mermaid` blocks
|
||||
* (mermaid needs a DOM, so it runs here, not in the host). Bundled by esbuild as
|
||||
* a standalone IIFE → out/media/preview.js, so mermaid never enters the host
|
||||
* bundle. No network, no LLM.
|
||||
*/
|
||||
import mermaid from "mermaid";
|
||||
// Imported so esbuild emits the sibling out/media/preview.css (the controller
|
||||
// links it into the sealed shell via asWebviewUri).
|
||||
import "./preview.css";
|
||||
|
||||
declare function acquireVsCodeApi(): { postMessage(m: unknown): void };
|
||||
|
||||
interface RenderMessage {
|
||||
type: "render";
|
||||
mode: "on" | "off";
|
||||
html: string;
|
||||
epoch?: string;
|
||||
summary?: { added: number; removed: number; proposals: number };
|
||||
/** F11: false on a non-authorable doc → Pin + Ask-Claude controls disabled. */
|
||||
authorable?: boolean;
|
||||
}
|
||||
|
||||
const vscodeApi = acquireVsCodeApi();
|
||||
const body = document.getElementById("cw-body")!;
|
||||
const header = document.getElementById("cw-epoch")!;
|
||||
const summary = document.getElementById("cw-summary")!;
|
||||
const legend = document.getElementById("cw-legend")!;
|
||||
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
|
||||
const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null;
|
||||
const askEl = document.getElementById("cw-ask") as HTMLButtonElement | null;
|
||||
const acceptAllEl = document.getElementById("cw-acceptall") as HTMLButtonElement | null;
|
||||
|
||||
// F10: the annotations on/off toggle.
|
||||
annotationsEl?.addEventListener("change", () => {
|
||||
vscodeApi.postMessage({ type: "setMode", mode: annotationsEl.checked ? "on" : "off" });
|
||||
});
|
||||
|
||||
// F11 (SLICE-1): Pin baseline — post intent; the host pins via the F6 store (INV-35).
|
||||
pinEl?.addEventListener("click", () => {
|
||||
vscodeApi.postMessage({ type: "pinBaseline" });
|
||||
});
|
||||
|
||||
// F11 (SLICE-4): the single adaptive Ask-Claude button. Its label flips on
|
||||
// `selectionchange` (Edit Selection when live text is selected in the preview,
|
||||
// Edit Document otherwise), and a click resolves the selection to a SOURCE range
|
||||
// via the nearest `data-src` ancestors (INV-36) — the webview's sole mapping
|
||||
// duty. A selection that resolves to no live block falls back to document scope.
|
||||
|
||||
/** Walk up from a DOM node to the nearest block carrying data-src offsets (INV-36). */
|
||||
function nearestSrc(node: Node | null): HTMLElement | null {
|
||||
let el: HTMLElement | null = node instanceof HTMLElement ? node : (node?.parentElement ?? null);
|
||||
while (el && el !== body) {
|
||||
if (el.dataset.srcStart !== undefined && el.dataset.srcEnd !== undefined) return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The source [start,end) union of the live blocks a non-empty body selection touches, or null. */
|
||||
function selectionSrcRange(): { start: number; end: number } | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
|
||||
const ends = [nearestSrc(sel.anchorNode), nearestSrc(sel.focusNode)].filter(
|
||||
(e): e is HTMLElement => e !== null,
|
||||
);
|
||||
if (ends.length === 0) return null; // selection touches no live-source block
|
||||
const starts = ends.map((e) => Number(e.dataset.srcStart));
|
||||
const stops = ends.map((e) => Number(e.dataset.srcEnd));
|
||||
return { start: Math.min(...starts), end: Math.max(...stops) };
|
||||
}
|
||||
|
||||
function updateAskLabel(): void {
|
||||
if (!askEl) return;
|
||||
askEl.textContent = selectionSrcRange()
|
||||
? "✦ Ask Claude to Edit Selection"
|
||||
: "✦ Ask Claude to Edit Document";
|
||||
}
|
||||
|
||||
document.addEventListener("selectionchange", updateAskLabel);
|
||||
|
||||
askEl?.addEventListener("click", () => {
|
||||
const range = selectionSrcRange();
|
||||
if (range) {
|
||||
vscodeApi.postMessage({ type: "askClaude", scope: "selection", start: range.start, end: range.end });
|
||||
} else {
|
||||
vscodeApi.postMessage({ type: "askClaude", scope: "document" });
|
||||
}
|
||||
});
|
||||
|
||||
// #46 (INV-42): Accept all — batch-accept every pending proposal (intent only).
|
||||
acceptAllEl?.addEventListener("click", () => {
|
||||
vscodeApi.postMessage({ type: "acceptAll" });
|
||||
});
|
||||
|
||||
// F12/#64: Accept/Reject (+ caret → accept-all/reject-all) on a proposal block.
|
||||
body.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
|
||||
if (!btn) return;
|
||||
const id = btn.closest<HTMLElement>(".cw-proposal")?.dataset.proposalId;
|
||||
const action = btn.dataset.action;
|
||||
if (action === "acceptAll") return void vscodeApi.postMessage({ type: "acceptAll" });
|
||||
if (action === "rejectAll") return void vscodeApi.postMessage({ type: "rejectAll" });
|
||||
if (id && (action === "accept" || action === "reject")) {
|
||||
vscodeApi.postMessage({ type: action, proposalId: id });
|
||||
}
|
||||
});
|
||||
|
||||
function themeFor(): "dark" | "default" {
|
||||
return document.body.classList.contains("vscode-dark") ||
|
||||
document.body.classList.contains("vscode-high-contrast")
|
||||
? "dark"
|
||||
: "default";
|
||||
}
|
||||
|
||||
async function renderMermaid(): Promise<void> {
|
||||
const nodes = Array.from(body.querySelectorAll<HTMLElement>("pre.mermaid"));
|
||||
if (nodes.length === 0) return;
|
||||
mermaid.initialize({ startOnLoad: false, theme: themeFor(), securityLevel: "strict" });
|
||||
try {
|
||||
await mermaid.run({ nodes });
|
||||
} catch {
|
||||
// mermaid.run already marks failed nodes; ensure a visible chip per failure.
|
||||
for (const n of nodes) {
|
||||
if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
|
||||
const msg = event.data;
|
||||
if (msg?.type !== "render") return;
|
||||
body.innerHTML = msg.html;
|
||||
updateAskLabel(); // new content clears any selection → reset the adaptive label
|
||||
// F11 (PUC-1/7): disable edit controls on a non-authorable doc (reading stays on).
|
||||
const authorable = msg.authorable !== false;
|
||||
if (pinEl) pinEl.disabled = !authorable;
|
||||
if (askEl) askEl.disabled = !authorable;
|
||||
const on = msg.mode === "on";
|
||||
if (annotationsEl) annotationsEl.checked = on;
|
||||
// #46: Accept all shows only with ≥2 pending proposals, on an authorable doc,
|
||||
// in the annotated (on) state — a single proposal is just a ✓ in place.
|
||||
if (acceptAllEl) acceptAllEl.hidden = !on || !authorable || (msg.summary?.proposals ?? 0) < 2;
|
||||
// Off-state is a clean preview: hide the review chrome.
|
||||
header.hidden = !on;
|
||||
summary.hidden = !on;
|
||||
legend.hidden = true;
|
||||
if (on) {
|
||||
header.textContent = `Review since ${msg.epoch ?? ""}`;
|
||||
summary.innerHTML =
|
||||
`<span class="cw-add">+${msg.summary?.added ?? 0}</span> ` +
|
||||
`<span class="cw-del">−${msg.summary?.removed ?? 0}</span> ` +
|
||||
`<span class="cw-prop">${msg.summary?.proposals ?? 0} proposal${(msg.summary?.proposals ?? 0) === 1 ? "" : "s"}</span>`;
|
||||
}
|
||||
void renderMermaid();
|
||||
});
|
||||
+126
-36
@@ -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": {
|
||||
|
||||
@@ -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 1–4** (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.
|
||||
@@ -190,5 +190,17 @@
|
||||
},
|
||||
"0064": {
|
||||
"title": ""
|
||||
},
|
||||
"0065": {
|
||||
"title": ""
|
||||
},
|
||||
"0066": {
|
||||
"title": ""
|
||||
},
|
||||
"0067": {
|
||||
"title": ""
|
||||
},
|
||||
"0068": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>`;
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\n+$/, "");
|
||||
}
|
||||
+152
-31
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,537 +0,0 @@
|
||||
/**
|
||||
* TrackChangesPreviewController — F7 vscode layer (spec §6.2/§6.4). Owns one
|
||||
* sealed webview panel per markdown document, beside the source editor. On open /
|
||||
* debounced edit / F6 baseline-epoch change it reads the baseline (from the
|
||||
* reused DiffViewController) + the live buffer, runs the pure render engine, and
|
||||
* posts the HTML. Pure read-only: never mutates the document, sidecar, or
|
||||
* baseline (INV-20). The webview is sealed: local assets only, strict CSP,
|
||||
* per-load nonce, no network (INV-21).
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import * as vscode from "vscode";
|
||||
import type { DiffViewController } from "./diffViewController";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import type { ProposalController } from "./proposalController";
|
||||
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, landedTextOf, type BlockOp } from "./trackChangesModel";
|
||||
import { buildFingerprint } from "./anchorer";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||
import type { LiveProgressUi } from "./liveProgressUi";
|
||||
import { promptEditInstruction } from "./editInstructionInput";
|
||||
|
||||
/**
|
||||
* F11: a host edit turn (selection/document text + instruction → rewrite).
|
||||
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
|
||||
* the arg is optional so existing test stubs that ignore it stay valid.
|
||||
*/
|
||||
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
|
||||
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
|
||||
type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
|
||||
|
||||
const VIEW_TYPE = "cowriting.trackChangesPreview";
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
/**
|
||||
* Inbound webview→host messages (intent only — the sealed webview never mutates,
|
||||
* INV-21/35). F10 carried the annotations toggle + ✓/✗ proposal decisions; F11
|
||||
* adds the toolbar intents (pin baseline / ask Claude).
|
||||
*/
|
||||
type ToolbarMsg =
|
||||
| { type: "setMode"; mode: "on" | "off" }
|
||||
| { type: "accept"; proposalId: string }
|
||||
| { type: "reject"; proposalId: string }
|
||||
| { type: "pinBaseline" }
|
||||
| { type: "askClaude"; scope: "document" }
|
||||
| { type: "askClaude"; scope: "selection"; start: number; end: number }
|
||||
| { type: "acceptAll" }
|
||||
| { type: "rejectAll" };
|
||||
|
||||
export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly panels = new Map<string, vscode.WebviewPanel>();
|
||||
private readonly lastModel = new Map<string, BlockOp[]>();
|
||||
private readonly debounces = new Map<string, NodeJS.Timeout>();
|
||||
/** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */
|
||||
private readonly mode = new Map<string, "on" | "off">();
|
||||
/** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */
|
||||
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88);
|
||||
/**
|
||||
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
|
||||
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
|
||||
*/
|
||||
private editTurn: EditTurn = async (instruction, text, opts) => {
|
||||
const { runEditTurn } = await import("./liveTurn");
|
||||
return runEditTurn(instruction, text, opts);
|
||||
};
|
||||
/**
|
||||
* The instruction prompt (the multi-line split-below webview box) for BOTH the
|
||||
* selection and document cases. A field so host E2E can stub it — the webview
|
||||
* DOM can't run in CI (mirrors `editTurn`). Also used by the editor's
|
||||
* `cowriting.editSelection` command (via this controller).
|
||||
*/
|
||||
askEditInstruction: (header: string) => Promise<string | undefined> = promptEditInstruction;
|
||||
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
|
||||
private turnSeq = 0;
|
||||
private nextTurnSeq(): number {
|
||||
return ++this.turnSeq;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly diffView: DiffViewController,
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
private readonly attribution: AttributionController,
|
||||
private readonly proposals: ProposalController,
|
||||
private readonly liveProgressUi: LiveProgressUi,
|
||||
) {
|
||||
this.disposables.push(
|
||||
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
|
||||
// the palette / keybinding pass nothing → fall back to the active editor.
|
||||
// #41: the explorer/tab right-click also passes the clicked Uri, which may
|
||||
// not be an open document yet — open it so we preview the clicked file, not
|
||||
// whatever happens to be the active editor.
|
||||
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", async (uri?: vscode.Uri) => {
|
||||
if (uri) {
|
||||
const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
|
||||
this.show(open ?? (await vscode.workspace.openTextDocument(uri)));
|
||||
return;
|
||||
}
|
||||
this.show(vscode.window.activeTextEditor?.document);
|
||||
}),
|
||||
// F11: document-scoped Ask-Claude (also reused by #42's reach gateways).
|
||||
// Edits a markdown doc; the rewrite is diffed into F4 proposals.
|
||||
// #42 (INV-38): the editor/title/context (tab) entry passes the clicked
|
||||
// tab's resource Uri — target THAT document, opening it if it isn't already
|
||||
// an open buffer (mirrors showTrackChangesPreview's #41 resolution); the
|
||||
// palette / keybinding / editor/context pass nothing → the active editor.
|
||||
vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => {
|
||||
const doc = uri
|
||||
? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ??
|
||||
(await vscode.workspace.openTextDocument(uri))
|
||||
: vscode.window.activeTextEditor?.document;
|
||||
if (!doc || !this.isMarkdown(doc)) {
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
|
||||
return;
|
||||
}
|
||||
void this.askClaude(doc, { kind: "document" });
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
|
||||
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
|
||||
this.proposals.onDidChangeProposals(({ uri }) => {
|
||||
this.refreshByUri(uri);
|
||||
this.updateStatus(uri);
|
||||
}),
|
||||
this.statusItem,
|
||||
);
|
||||
this.statusItem.command = "cowriting.showTrackChangesPreview";
|
||||
}
|
||||
|
||||
private isMarkdown(document: vscode.TextDocument): boolean {
|
||||
return document.languageId === "markdown";
|
||||
}
|
||||
|
||||
/** Open or reveal the preview for a markdown document (PUC-1). */
|
||||
show(document: vscode.TextDocument | undefined): void {
|
||||
if (!document || !this.isMarkdown(document)) {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: open a Markdown document to use the track-changes preview (F6 covers other files).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = document.uri.toString();
|
||||
const existing = this.panels.get(key);
|
||||
if (existing) {
|
||||
existing.reveal(vscode.ViewColumn.Beside);
|
||||
this.refresh(document);
|
||||
return;
|
||||
}
|
||||
const name = path.basename(document.uri.path) || "untitled";
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
VIEW_TYPE,
|
||||
`Review: ${name}`,
|
||||
{ viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: false,
|
||||
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "out", "media")],
|
||||
},
|
||||
);
|
||||
panel.webview.html = this.shellHtml(panel.webview);
|
||||
panel.onDidDispose(
|
||||
() => {
|
||||
this.panels.delete(key);
|
||||
this.lastModel.delete(key);
|
||||
this.mode.delete(key);
|
||||
// A panel is gone: re-show the off-panel indicator if proposals remain.
|
||||
this.updateStatus(key);
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
// F10/F11: the webview posts the annotations toggle, ✓/✗ proposal decisions,
|
||||
// and (F11) the toolbar intents (pin baseline / ask Claude) back to the host.
|
||||
panel.webview.onDidReceiveMessage(
|
||||
(m: ToolbarMsg) => this.handleWebviewMessage(document, m),
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
this.panels.set(key, panel);
|
||||
// A panel is now open for this doc — the off-panel indicator is redundant.
|
||||
this.hideStatus();
|
||||
this.refresh(document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an inbound webview intent through the existing seams (INV-35): the
|
||||
* annotations toggle + ✓/✗ proposal decisions (F10) and the toolbar gestures
|
||||
* (F11). Pin targets the PREVIEWED document (`DiffViewController.pin`), not the
|
||||
* active editor — the preview knows its bound doc (§6.7).
|
||||
*/
|
||||
private handleWebviewMessage(document: vscode.TextDocument, m: ToolbarMsg): void {
|
||||
const key = document.uri.toString();
|
||||
if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) {
|
||||
this.mode.set(key, m.mode);
|
||||
this.refresh(document);
|
||||
} else if (m?.type === "accept" && m.proposalId) {
|
||||
void this.proposals
|
||||
.acceptById(this.proposals.keyFor(document), m.proposalId)
|
||||
.then(() => this.refresh(document));
|
||||
} else if (m?.type === "reject" && m.proposalId) {
|
||||
void this.proposals.rejectByIdInPlace(this.proposals.keyFor(document), m.proposalId).then(() => this.refresh(document));
|
||||
} else if (m?.type === "pinBaseline") {
|
||||
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
|
||||
this.diffView.pin(document);
|
||||
} else if (m?.type === "askClaude") {
|
||||
const target: EditTarget =
|
||||
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
|
||||
void this.askClaude(document, target);
|
||||
} else if (m?.type === "acceptAll") {
|
||||
// #46 (INV-42): batch-accept every pending proposal on this doc, then report.
|
||||
void this.acceptAll(document);
|
||||
} else if (m?.type === "rejectAll") {
|
||||
void this.rejectAll(document);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #46 (INV-42): apply every pending proposal on the document through the F4
|
||||
* accept seam (orphan-skip) and report the applied-vs-skipped tally. No
|
||||
* confirmation dialog — VS Code undo restores (parity with single accept).
|
||||
* Public so the `cowriting.acceptAllProposals` command can reach it for the
|
||||
* active doc (not only the webview button).
|
||||
*/
|
||||
async acceptAll(document: vscode.TextDocument): Promise<void> {
|
||||
const { applied, skipped } = await this.proposals.acceptAllProposals(document);
|
||||
this.refresh(document);
|
||||
if (applied === 0 && skipped === 0) return;
|
||||
const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : "";
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** #64 (INV-53): revert every pending proposal on the document; report the count. */
|
||||
async rejectAll(document: vscode.TextDocument): Promise<void> {
|
||||
const { reverted } = await this.proposals.rejectAll(document);
|
||||
this.refresh(document);
|
||||
if (reverted > 0) {
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
|
||||
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
||||
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
|
||||
*/
|
||||
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
|
||||
// Both scopes use the same multi-line split-below webview box; only the header
|
||||
// (and the downstream proposal logic) differs. For a selection the document
|
||||
// above keeps the selection highlighted while the box is open.
|
||||
const header =
|
||||
target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection";
|
||||
const instruction = await this.askEditInstruction(header);
|
||||
if (!instruction) return;
|
||||
try {
|
||||
const ids = await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: "Cowriting: asking Claude…",
|
||||
cancellable: true,
|
||||
},
|
||||
async (progress, token) => {
|
||||
const ui = this.liveProgressUi.begin(instruction, progress, token);
|
||||
try {
|
||||
return await this.runEditAndPropose(document, target, instruction, {
|
||||
onProgress: ui.onProgress,
|
||||
signal: ui.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
// #60 (INV-47): a user cancel proposes nothing (the benign empty path).
|
||||
if (token.isCancellationRequested) return [] as string[];
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
if (ids.length === 0) {
|
||||
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
|
||||
} else {
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: Claude proposed ${ids.length} edit${ids.length === 1 ? "" : "s"} — review ✓/✗ in the preview.`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F11/F12 (INV-35/39): run one host edit turn and record the result as F4
|
||||
* proposal(s) — a SELECTION yields one single-range proposal over the resolved
|
||||
* block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per
|
||||
* changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged
|
||||
* `granularity:"block"` so accept reconciles attribution per word (INV-40).
|
||||
* Never mutates the document (INV-10). Returns the created proposal ids.
|
||||
*/
|
||||
async runEditAndPropose(
|
||||
document: vscode.TextDocument,
|
||||
target: EditTarget,
|
||||
instruction: string,
|
||||
opts?: RunEditTurnOptions,
|
||||
): Promise<string[]> {
|
||||
const full = document.getText();
|
||||
// One turnId per gesture — the document case's N hunk-proposals all share it,
|
||||
// so a single rewrite groups as one agent turn (parity with editSelection).
|
||||
const turnId = `turn-${this.nextTurnSeq()}`;
|
||||
const provenance = (turn: EditTurnResult) =>
|
||||
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
|
||||
if (target.kind === "range") {
|
||||
const selected = full.slice(target.start, target.end);
|
||||
const turn = await this.editTurn(instruction, selected, opts);
|
||||
if (turn.replacement === "" || turn.replacement === selected) return [];
|
||||
const fp = buildFingerprint(full, { start: target.start, end: target.end });
|
||||
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
|
||||
return id ? [id] : [];
|
||||
}
|
||||
const turn = await this.editTurn(instruction, full, opts);
|
||||
const ids: string[] = [];
|
||||
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
|
||||
// granularity — one proposal per changed block (the unit a human reviews) —
|
||||
// not per word. Each is tagged `granularity:"block"` so accept reconciles
|
||||
// attribution per word inside the block (INV-40).
|
||||
for (const h of diffToBlockHunks(full, turn.replacement)) {
|
||||
const fp = buildFingerprint(full, { start: h.start, end: h.end });
|
||||
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), {
|
||||
turnId,
|
||||
instruction,
|
||||
granularity: "block",
|
||||
});
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private onEdit(document: vscode.TextDocument): void {
|
||||
const key = document.uri.toString();
|
||||
if (!this.panels.has(key)) return;
|
||||
const pending = this.debounces.get(key);
|
||||
if (pending) clearTimeout(pending);
|
||||
this.debounces.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
this.debounces.delete(key);
|
||||
this.refresh(document);
|
||||
}, DEBOUNCE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
private refreshByUri(uri: string): void {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (doc) this.refresh(doc);
|
||||
}
|
||||
|
||||
/** Recompute the model + post HTML to the panel for its current mode (no-op if no panel). */
|
||||
refresh(document: vscode.TextDocument): void {
|
||||
const key = document.uri.toString();
|
||||
const panel = this.panels.get(key);
|
||||
if (!panel) return;
|
||||
const mode = this.mode.get(key) ?? "on";
|
||||
const current = document.getText();
|
||||
const baseline = this.diffView.getBaseline(key);
|
||||
const baselineText = baseline?.text ?? current; // no baseline → no change-marks
|
||||
const ops = diffBlocks(baselineText, current);
|
||||
this.lastModel.set(key, ops);
|
||||
// F11 (PUC-1/7): edit controls are inert on a non-authorable doc (reading stays allowed).
|
||||
const authorable = isAuthorable(document.uri.scheme);
|
||||
if (mode === "off") {
|
||||
void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current), authorable });
|
||||
return;
|
||||
}
|
||||
const spans = this.attribution.spansFor(document);
|
||||
const proposals = this.proposals.listProposals(document);
|
||||
// F12/#64 (INV-50): count added/removed against the LANDED text (current minus
|
||||
// pending proposals), matching the body — a pending change shows once, as a
|
||||
// proposal, and is not also tallied as a landed add/remove.
|
||||
const landedOps = diffBlocks(baselineText, landedTextOf(current, proposals));
|
||||
const summary = {
|
||||
added: landedOps.filter((o) => o.kind === "added").length,
|
||||
removed: landedOps.filter((o) => o.kind === "removed").length,
|
||||
proposals: proposals.length,
|
||||
};
|
||||
void panel.webview.postMessage({
|
||||
type: "render",
|
||||
mode,
|
||||
html: renderReview(baselineText, current, spans, proposals, { pinned: baseline?.reason === "pinned" }),
|
||||
epoch: this.epochLabel(baseline),
|
||||
summary,
|
||||
authorable,
|
||||
});
|
||||
}
|
||||
|
||||
/** F10 (PUC-6): off-panel proposal indicator on the active doc. Hidden when a panel is open. */
|
||||
private updateStatus(uri: string): void {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (!doc) {
|
||||
this.hideStatus();
|
||||
return;
|
||||
}
|
||||
const n = this.proposals.listProposals(doc).length;
|
||||
if (n === 0 || this.panels.has(uri)) {
|
||||
this.hideStatus();
|
||||
return;
|
||||
}
|
||||
this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`;
|
||||
this.statusItem.tooltip = "Cowriting: open the review preview to accept/reject Claude's proposals";
|
||||
this.statusItem.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the off-panel indicator AND clear its text, so the `statusText()` seam
|
||||
* is honest: a hidden indicator reports `undefined` (not its stale last value).
|
||||
*/
|
||||
private hideStatus(): void {
|
||||
this.statusItem.text = "";
|
||||
this.statusItem.hide();
|
||||
}
|
||||
|
||||
private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string {
|
||||
if (!baseline) return "opened (no baseline yet)";
|
||||
const time = new Date(baseline.capturedAt).toLocaleTimeString();
|
||||
switch (baseline.reason) {
|
||||
case "machine-landing":
|
||||
return `Claude landed ${time}`;
|
||||
case "pinned":
|
||||
return `pinned ${time}`;
|
||||
default:
|
||||
return `opened ${time}`;
|
||||
}
|
||||
}
|
||||
|
||||
private shellHtml(webview: vscode.Webview): string {
|
||||
const nonce = randomBytes(16).toString("base64");
|
||||
const scriptUri = webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.js"),
|
||||
);
|
||||
const styleUri = webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.css"),
|
||||
);
|
||||
// Sealed CSP (INV-21): no network. 'unsafe-inline' style is required only for
|
||||
// mermaid's dynamically injected <style> tags; scripts are nonce-gated and
|
||||
// strictly local (no remote/CDN script source).
|
||||
const csp =
|
||||
`default-src 'none'; ` +
|
||||
`img-src ${webview.cspSource} data:; ` +
|
||||
`font-src ${webview.cspSource}; ` +
|
||||
`style-src ${webview.cspSource} 'unsafe-inline'; ` +
|
||||
`script-src 'nonce-${nonce}';`;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}" />
|
||||
<link href="${styleUri}" rel="stylesheet" />
|
||||
<title>Track changes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="cw-header">
|
||||
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
|
||||
<button id="cw-pin" type="button" title="Pin the review baseline to now (clears the change-marks)">⌖ Pin baseline</button>
|
||||
<button id="cw-ask" type="button" title="Ask Claude to edit (the selection if any, else the whole document)">✦ Ask Claude to Edit Document</button>
|
||||
<button id="cw-acceptall" type="button" hidden title="Accept every pending Claude proposal on this document">✓✓ Accept all</button>
|
||||
<span id="cw-epoch">Review</span>
|
||||
<span id="cw-summary"></span>
|
||||
<span id="cw-legend"></span>
|
||||
</div>
|
||||
<div id="cw-body"></div>
|
||||
<script nonce="${nonce}" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ---- test seam (§6.4) ----
|
||||
isOpen(uriString: string): boolean {
|
||||
return this.panels.has(uriString);
|
||||
}
|
||||
/**
|
||||
* F11 test seam: deliver an inbound webview message to the real routing, as if
|
||||
* the sealed webview had posted it. Exercises message→seam wiring without a
|
||||
* live webview DOM (which is manual-smoke only). No-op if no doc/panel.
|
||||
*/
|
||||
receiveMessage(uriString: string, m: ToolbarMsg): void {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m);
|
||||
}
|
||||
/** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */
|
||||
setEditTurnForTest(fn: EditTurn): void {
|
||||
this.editTurn = fn;
|
||||
}
|
||||
/**
|
||||
* F11 (PUC-1/7): whether the previewed doc's edit controls (Pin + Ask-Claude)
|
||||
* are enabled — true only for an authorable doc. The annotations toggle is
|
||||
* always active (reading is always allowed). False if no panel/doc.
|
||||
*/
|
||||
editControlsEnabled(uriString: string): boolean {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
return doc ? isAuthorable(doc.uri.scheme) : false;
|
||||
}
|
||||
getLastModel(uriString: string): BlockOp[] | undefined {
|
||||
return this.lastModel.get(uriString);
|
||||
}
|
||||
/** F10 test seam: the review HTML the panel would post for a doc (on-state). */
|
||||
renderHtmlFor(uriString: string): string {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
if (!doc) return "";
|
||||
const current = doc.getText();
|
||||
const baseline = this.diffView.getBaseline(uriString);
|
||||
return renderReview(
|
||||
baseline?.text ?? current,
|
||||
current,
|
||||
this.attribution.spansFor(doc),
|
||||
this.proposals.listProposals(doc),
|
||||
{ pinned: baseline?.reason === "pinned" },
|
||||
);
|
||||
}
|
||||
/** F10: current annotations mode for a panel (default on). */
|
||||
getMode(uriString: string): "on" | "off" {
|
||||
return this.mode.get(uriString) ?? "on";
|
||||
}
|
||||
/** F10: set the annotations mode and re-render (the programmatic twin of the header toggle). */
|
||||
setMode(uriString: string, mode: "on" | "off"): void {
|
||||
this.mode.set(uriString, mode);
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
if (doc) this.refresh(doc);
|
||||
}
|
||||
/** F10 test seam (SLICE-4 E2E): the off-panel status-bar indicator text, if shown. */
|
||||
statusText(): string | undefined {
|
||||
return this.statusItem.text || undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const t of this.debounces.values()) clearTimeout(t);
|
||||
for (const p of this.panels.values()) p.dispose();
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
+52
-1
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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!;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,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 = [];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (F2–F6 pattern): later tests consume earlier state.
|
||||
suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, no LLM)", () => {
|
||||
const TARGET = "A target sentence Claude will rewrite via the seam.";
|
||||
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
|
||||
|
||||
test("open on a markdown doc → panel open, opened baseline shows no changes (PUC-1)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), false, "no panel yet");
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), true, "panel open");
|
||||
const model = api.trackChangesPreviewController.getLastModel(docUri());
|
||||
assert.ok(model && model.length > 0, "a model was computed");
|
||||
assert.ok(
|
||||
model!.every((o) => o.kind === "unchanged"),
|
||||
"baseline == buffer at open → every block unchanged",
|
||||
);
|
||||
void doc;
|
||||
});
|
||||
|
||||
test("typing produces a changed/added block (PUC-2)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
const end = doc.positionAt(doc.getText().length);
|
||||
edit.insert(doc.uri, end, "\n\nA freshly typed paragraph.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||
await settle();
|
||||
assert.ok(
|
||||
kinds(api).some((k) => k === "added" || k === "changed"),
|
||||
"an added/changed block appears after typing",
|
||||
);
|
||||
});
|
||||
|
||||
test("accepting a proposal 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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 --> 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 --> 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user