F12: inline editable proposed-change diff in the Markdown editor + Accept/Reject control parity (#64) (#66)
This commit was merged in pull request #66.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -89,6 +89,11 @@ pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
|
|||||||
}
|
}
|
||||||
.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
|
.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
|
||||||
.cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); 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. */
|
/* 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 { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
|
||||||
|
|||||||
+4
-3
@@ -94,13 +94,14 @@ acceptAllEl?.addEventListener("click", () => {
|
|||||||
vscodeApi.postMessage({ type: "acceptAll" });
|
vscodeApi.postMessage({ type: "acceptAll" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
|
// F12/#64: Accept/Reject (+ caret → accept-all/reject-all) on a proposal block.
|
||||||
body.addEventListener("click", (e) => {
|
body.addEventListener("click", (e) => {
|
||||||
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
|
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const block = btn.closest<HTMLElement>(".cw-proposal");
|
const id = btn.closest<HTMLElement>(".cw-proposal")?.dataset.proposalId;
|
||||||
const id = block?.dataset.proposalId;
|
|
||||||
const action = btn.dataset.action;
|
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")) {
|
if (id && (action === "accept" || action === "reject")) {
|
||||||
vscodeApi.postMessage({ type: action, proposalId: id });
|
vscodeApi.postMessage({ type: action, proposalId: id });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,21 @@
|
|||||||
"command": "cowriting.acceptAllProposals",
|
"command": "cowriting.acceptAllProposals",
|
||||||
"title": "Accept All Claude Proposals",
|
"title": "Accept All Claude Proposals",
|
||||||
"category": "Cowriting"
|
"category": "Cowriting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.rejectAllProposals",
|
||||||
|
"title": "Reject All Claude Proposals",
|
||||||
|
"category": "Cowriting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.proposalAcceptMenu",
|
||||||
|
"title": "Accept Claude Proposal",
|
||||||
|
"category": "Cowriting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.proposalRejectMenu",
|
||||||
|
"title": "Reject Claude Proposal",
|
||||||
|
"category": "Cowriting"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
@@ -142,6 +157,18 @@
|
|||||||
{
|
{
|
||||||
"command": "cowriting.acceptAllProposals",
|
"command": "cowriting.acceptAllProposals",
|
||||||
"when": "editorLangId == markdown"
|
"when": "editorLangId == markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.rejectAllProposals",
|
||||||
|
"when": "editorLangId == markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.proposalAcceptMenu",
|
||||||
|
"when": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.proposalRejectMenu",
|
||||||
|
"when": "false"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor/title": [
|
"editor/title": [
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
---
|
||||||
|
status: graduated
|
||||||
|
---
|
||||||
|
# Solution Design: Inline editable proposed-change diff in the Markdown editor (#64)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| **Author(s)** | Ben Stull (with Claude) |
|
||||||
|
| **Reviewers / approvers** | Ben Stull |
|
||||||
|
| **Status** | `graduated` |
|
||||||
|
| **Version** | v0.1.1 |
|
||||||
|
| **Source artifacts** | Feature `benstull/vscode-cowriting-plugin#64` (Inline editable proposed-change diff in the Markdown editor + Accept/Reject controls in both surfaces, `type/feature`, `priority/P2`) · Brainstorming session `vscode-cowriting-plugin-0058` (2026-06-26) · Builds on (all shipped): F4 `#12` (propose/accept seam, INV-9/10/11/13), F6 `#17` (baseline / machine-landing), F7 `#21` (rendered preview, pure render engine INV-22), F10 `#29` (interactive review preview — the **clean-editor** decision INV-32; ✓/✗ accept-reject), F11 `#43` (preview toolbar, `data-src` block→source mapping INV-36), document-edit-flow `#42/#47/#46` (block proposals INV-39/40, accept-all INV-42) · Parent specs (graduated): `coauthoring-propose-accept.md`, `coauthoring-diff-view.md`, `coauthoring-rendered-preview.md`, `coauthoring-interactive-review.md`, `coauthoring-document-edit-flow.md` · Lineage: `ben.stull/rfc-app#48` |
|
||||||
|
|
||||||
|
**Change log**
|
||||||
|
|
||||||
|
| Date | Version | Change | By |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 2026-06-26 | v0.1.1 | Implementation refinement (planning-and-executing session 0059): made the **re-anchor** explicit. Optimistic apply (§3.2) must store the pre-apply text on a new `Proposal.original` field **and** re-fingerprint the proposal's anchor to the *applied* text — F4's `fp.text` is the *original* target, which leaves the buffer once the replacement lands, so `resolve()` would orphan every applied proposal otherwise. `finalizeInPlace`/`revertInPlace`/decorate all key off the re-anchored fp; revert restores `original`. Reload-safety (INV-51/54): a proposal already carrying `original` is never re-captured (the in-memory applied-set is empty after a window reload), so the revert target survives save+reload. Shipped #64 (PR, session 0059). |
|
||||||
|
| 2026-06-26 | v0.1.0 | Initial draft — brainstorming session 0058. Four forks locked with the operator: **(1) editor model** = *optimistic apply + decorations* — on propose, the editor buffer becomes the would-be-accepted text (insertions real & editable & tinted; deletions shown as struck-red non-editable hints); accept = finalize-in-place, reject = revert. **(2) timing** = *on proposal* (turn complete), not a live token-stream into the editor (that stays in #60's notification/OutputChannel). **(3) editor affordance** = CodeLens `Accept ▾ / Reject ▾` above each block, `▾` → QuickPick (this / all); the webview keeps HTML buttons with the same dropdown. **(4) controls parity** = Accept / Reject / Accept-all / Reject-all reachable from **both** the editor and the webview. Two sub-decisions confirmed: a **dedicated `EditorProposalController`** owns optimistic-apply + decorations + CodeLens (keeps `ProposalController` the pure F4 state/seam owner); **saving while pending persists** the proposed (accepted-result) text. Reverses INV-32 and INV-10; supersedes the ✓/✗ glyph controls. | Ben Stull + Claude |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Business Context
|
||||||
|
|
||||||
|
### 1.1 Executive Summary
|
||||||
|
|
||||||
|
Today, when Claude proposes an edit, the change is shown **only in the rendered
|
||||||
|
review webview** (`<del>old</del><ins>new</ins>` with ✓/✗ controls); the Markdown
|
||||||
|
**editor stays deliberately clean** (F10 / INV-32 — "the preview is the single
|
||||||
|
review surface"). The writer who lives in the editor sees nothing: to review what
|
||||||
|
Claude proposed they must open the preview panel, and they cannot *edit* the
|
||||||
|
proposed text in place — they can only accept or reject it wholesale in the
|
||||||
|
webview.
|
||||||
|
|
||||||
|
This design brings the proposed change **into the Markdown editor itself**:
|
||||||
|
editable, with a track-changes diff that matches the webview and the post-accept
|
||||||
|
result **exactly**. It also replaces the per-proposal `✓`/`✗` glyphs with labelled
|
||||||
|
**Accept** / **Reject** controls — each carrying a `▾` dropdown for **Accept all**
|
||||||
|
/ **Reject all** — and makes all four actions reachable from **both** the editor
|
||||||
|
and the webview. The writer can now read, tweak, and resolve a proposal without
|
||||||
|
leaving the document.
|
||||||
|
|
||||||
|
### 1.2 Background
|
||||||
|
|
||||||
|
The plugin's review model (graduated specs `coauthoring-propose-accept.md`,
|
||||||
|
`coauthoring-rendered-preview.md`, `coauthoring-interactive-review.md`):
|
||||||
|
|
||||||
|
- A machine edit becomes an **F4 proposal** — a pending-only record
|
||||||
|
(`Proposal{ id, anchorId, replacement, granularity }` + a `Fingerprint` anchor
|
||||||
|
whose `text` is the exact target) that **never mutates the document** (INV-10),
|
||||||
|
re-resolved against the live text at accept (INV-11) and cleared when
|
||||||
|
accepted/rejected (INV-13).
|
||||||
|
- The **render engine** (`trackChangesModel.ts`) is a pure, deterministic,
|
||||||
|
`vscode`-free unit (INV-22): it diffs at **block** granularity (INV-39), word-
|
||||||
|
merges prose blocks, and emits webview HTML — `renderReview` (annotated) /
|
||||||
|
`renderPlain` (clean) — with proposal blocks (`proposalBlockHtml`) carrying the
|
||||||
|
✓/✗ buttons and `data-src` offsets for selection mapping (INV-36).
|
||||||
|
- The **F10 decision (INV-32)** removed *all* in-editor decorations
|
||||||
|
(`grep` confirms zero `TextEditorDecorationType` / `setDecorations` in the tree)
|
||||||
|
so the webview is the sole review surface; `ProposalController` carries the note
|
||||||
|
*"no in-editor UI — INV-32 makes the rendered preview the single review surface."*
|
||||||
|
- The sealed webview is **intent-only** (INV-35): it posts `{accept|reject|
|
||||||
|
acceptAll|…}` to the host, which routes to `ProposalController.acceptById` /
|
||||||
|
`rejectById` / `acceptAllProposals` (#46 / INV-42). Accept **applies** the
|
||||||
|
replacement via the F4 seam (`applyAgentEdit`), which advances the F6 baseline
|
||||||
|
(machine-landing, INV-18).
|
||||||
|
|
||||||
|
This feature **deliberately reverses two of those decisions** for the editor
|
||||||
|
surface, and supersedes the ✓/✗ glyph controls in both surfaces.
|
||||||
|
|
||||||
|
### 1.3 Who feels it & why
|
||||||
|
|
||||||
|
The **human coauthor working in the editor** — the writer who asked Claude to edit
|
||||||
|
and wants to see, adjust, and resolve the result without context-switching to the
|
||||||
|
preview panel. The pain: the proposed change is invisible in the place they are
|
||||||
|
actually writing, and it is not editable at all (only accept/reject-able). P2
|
||||||
|
(issue #64): it materially deepens the inner-loop "edit-in-place" experience and
|
||||||
|
unifies the review controls, but the webview review path already works, so it is
|
||||||
|
an enhancement rather than a gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Product Design
|
||||||
|
|
||||||
|
### 2.1 The experience
|
||||||
|
|
||||||
|
When Claude's turn lands a proposal (or, for a document edit, N block proposals),
|
||||||
|
the **editor** shows the change as track-changes, in place:
|
||||||
|
|
||||||
|
```
|
||||||
|
Accept ▾ Reject ▾ ← CodeLens, above each proposed block
|
||||||
|
The quick ~~brown~~ red fox jumps over the lazy dog.
|
||||||
|
└ struck red ┘└ green/blue, editable ┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Insertions** are **real, editable buffer text**, tinted by origin
|
||||||
|
(green = human-origin, blue = LLM) — the writer can click in and edit them like
|
||||||
|
any other text.
|
||||||
|
- **Deletions** appear as a **struck-red, non-editable hint** rendered adjacent to
|
||||||
|
the insertion (a decoration, not buffer text) — visually mirroring the webview's
|
||||||
|
`<del>…</del>`.
|
||||||
|
- The buffer content **is exactly the would-be-accepted result**, so what the
|
||||||
|
writer sees (and can edit) is precisely what accepting produces.
|
||||||
|
|
||||||
|
Above each proposed block sit two **CodeLens** actions — **`Accept ▾`** and
|
||||||
|
**`Reject ▾`**. Clicking opens a small QuickPick:
|
||||||
|
|
||||||
|
```
|
||||||
|
Accept ▾ → ┌───────────────────────┐ Reject ▾ → ┌───────────────────────┐
|
||||||
|
│ Accept this proposal │ │ Reject this proposal │
|
||||||
|
│ Accept ALL proposals │ │ Reject ALL proposals │
|
||||||
|
└───────────────────────┘ └───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The **webview** shows the same proposal with the same diff, and its controls are
|
||||||
|
the parallel HTML buttons — **`Accept ▾`** / **`Reject ▾`** — where the `▾`
|
||||||
|
reveals *Accept all* / *Reject all*. The legacy ✓/✗ glyphs are **replaced** by
|
||||||
|
these labelled controls in both surfaces.
|
||||||
|
|
||||||
|
All four actions — **Accept**, **Reject**, **Accept all**, **Reject all** — are
|
||||||
|
reachable from **either** surface and route to the same controller logic
|
||||||
|
(INV-53).
|
||||||
|
|
||||||
|
### 2.2 Accept / Reject semantics (editable-in-place)
|
||||||
|
|
||||||
|
- **Accept** finalizes the change *that is already in the buffer*: it records the
|
||||||
|
span's attribution as landed, advances the F6 baseline (machine-landing), and
|
||||||
|
clears the proposal. It does **not** re-apply text (the text is already there) —
|
||||||
|
see §3.3.
|
||||||
|
- **Reject** reverts that block's region back to the stored original
|
||||||
|
(`Fingerprint.text`), and clears the proposal.
|
||||||
|
- **If the writer edited the inserted text before deciding:** **Accept** keeps
|
||||||
|
their edited text (their keystrokes layer on as human authorship over the
|
||||||
|
proposed span); **Reject** still reverts the whole block to the original.
|
||||||
|
- **Accept all / Reject all** operate on the current document's pending proposals,
|
||||||
|
in descending anchor order, skipping orphans (the #46 / INV-42 shape; **Reject
|
||||||
|
all is new**).
|
||||||
|
|
||||||
|
### 2.3 Save semantics
|
||||||
|
|
||||||
|
Because the proposed text is *in the buffer*, **saving while a proposal is pending
|
||||||
|
persists the proposed (accepted-result) text** to disk. The deletion hints are
|
||||||
|
decorations (never buffer text), so the **saved file is clean** — it contains the
|
||||||
|
accepted-result text, not yet "finalized" only in the sense of attribution /
|
||||||
|
baseline. "Pending" therefore means *provisional attribution, baseline not yet
|
||||||
|
advanced* — **not** "document unchanged" (INV-54). We deliberately **allow** the
|
||||||
|
save rather than block it or strip on save; once the editor shows the live
|
||||||
|
document, blocking saves would be the surprising behavior.
|
||||||
|
|
||||||
|
### 2.4 What this is *not* (non-goals)
|
||||||
|
|
||||||
|
- **Not live token-streaming into the editor.** The diff appears when the turn
|
||||||
|
produces a proposal; watching Claude type into the document is out of scope (the
|
||||||
|
live token stream is #60's notification + OutputChannel).
|
||||||
|
- **Not a new review *model*.** F4 proposals, anchoring (INV-11), block
|
||||||
|
granularity (INV-39/40), accept-all (INV-42) are reused; this adds an editor
|
||||||
|
*surface* and unifies the controls.
|
||||||
|
- **Not non-Markdown.** Same Markdown-gated scope as the rest of the review UI.
|
||||||
|
- **Not intra-diagram mermaid editing.** Atomic fences stay atomic (INV-23); a
|
||||||
|
mermaid block proposal is editable as its whole fence, not sub-diagram.
|
||||||
|
- **Not removing the webview.** The rendered preview remains a full review surface;
|
||||||
|
it gains label/dropdown parity, not a demotion.
|
||||||
|
|
||||||
|
### 2.5 Surfaces considered & rejected
|
||||||
|
|
||||||
|
| Editor approach | Why not |
|
||||||
|
| --- | --- |
|
||||||
|
| **Read-only decoration overlay** (doc unchanged; ghost insertions via decoration) | The inserted text would **not be editable** — fails the core "human-editable" requirement. |
|
||||||
|
| **Inline track-changes markup in the buffer** (`{~~old~~|++new++}`) | Pollutes the file on disk with markup until resolved; complex; conflicts with clean save. |
|
||||||
|
| **Native `vscode.diff` two-pane** | The two-pane diff was *removed* in #34 (F10) precisely to make one review surface; a separate diff editor is not "in the Markdown file." |
|
||||||
|
|
||||||
|
Chosen: **optimistic apply + decorations** — the only model that yields an
|
||||||
|
editable diff that is *exactly* the accepted result, in the document itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Engineering Design
|
||||||
|
|
||||||
|
### 3.1 Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
one F4 turn ───▶ │ ProposalController (F4 state/seam owner) │
|
||||||
|
(#12 propose) │ propose() · finalizeInPlace() · revertInPlace│
|
||||||
|
│ · rejectAll() · onDidChangeProposals │
|
||||||
|
└───────────────┬──────────────────────────────┘
|
||||||
|
│ proposals (pending records + anchors)
|
||||||
|
┌─────────────────────┴───────────────────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────────────────┐ ┌────────────────────────────┐
|
||||||
|
│ EditorProposalController │ shared │ TrackChangesPreview- │
|
||||||
|
│ (NEW, vscode host) │ pure diff │ Controller (webview) │
|
||||||
|
│ · optimistic buffer write │◀──────────────▶ │ · renderReview HTML │
|
||||||
|
│ · insertion tint deco │ trackChangesModel│ · Accept▾/Reject▾ buttons │
|
||||||
|
│ · deletion-hint deco │ (diff → plan) │ · dropdown → acceptAll/ │
|
||||||
|
│ · CodeLens Accept▾/Reject▾│ │ rejectAll │
|
||||||
|
└───────────────────────────┘ └────────────────────────────┘
|
||||||
|
▲ ▲
|
||||||
|
└──────────── both route actions to ───────────┘
|
||||||
|
ProposalController.{finalizeInPlace,revertInPlace,
|
||||||
|
acceptAll,rejectAll}
|
||||||
|
```
|
||||||
|
|
||||||
|
The decisive rule: **one pure diff is the single source of truth** for both
|
||||||
|
surfaces (INV-49). The editor renders it as *buffer text + decorations + CodeLens*;
|
||||||
|
the webview renders it as *HTML*. They cannot diverge because they consume the
|
||||||
|
same hunks.
|
||||||
|
|
||||||
|
### 3.2 Optimistic apply (on propose) — INV-48
|
||||||
|
|
||||||
|
`propose()` gains a buffer-write step, owned by `EditorProposalController` (so
|
||||||
|
`ProposalController` stays the pure state owner):
|
||||||
|
|
||||||
|
1. Record the pending proposal(s) + anchor(s) **exactly as today** (the sidecar
|
||||||
|
record is unchanged: `replacement`, `Fingerprint{text,before,after,lineHint}`,
|
||||||
|
`granularity`).
|
||||||
|
2. Write the proposed text into the **live editor buffer**: replace the resolved
|
||||||
|
anchor span with `replacement` (for a document turn, this makes the buffer
|
||||||
|
Claude's full proposed document; for a selection turn, the single span). This
|
||||||
|
write **does not advance the F6 baseline** — the baseline stays at the
|
||||||
|
pre-proposal text, so the change reads as *pending*, not *landed*.
|
||||||
|
3. Compute the **decoration plan** from the same per-block word/line diff used by
|
||||||
|
`acceptBlock` (`wordEditHunks(fp.text, replacement)`): insertion segments →
|
||||||
|
tinted ranges (buffer coords, offset by the live anchor); deletion segments →
|
||||||
|
struck-red hint injections (decoration `before`/`after` content) at the segment
|
||||||
|
boundary.
|
||||||
|
|
||||||
|
Critically, the optimistic write must **not** fire the F4 seam's
|
||||||
|
`onDidApplyAgentEdit` (which would advance the baseline / register a landed edit).
|
||||||
|
Optimistic apply is a *distinct* buffer-mutation path from accept; only **accept**
|
||||||
|
advances the baseline (§3.3). This is the load-bearing distinction that keeps the
|
||||||
|
change *pending* while *present*.
|
||||||
|
|
||||||
|
### 3.3 Accept = finalize-in-place; Reject = revert-in-place — INV-51
|
||||||
|
|
||||||
|
Because the proposed text is already in the buffer, the old "accept ⇒ apply via
|
||||||
|
seam" path would **double-apply**. The accept/reject paths are reworked:
|
||||||
|
|
||||||
|
- **`finalizeInPlace(id)`** — resolve the proposal's anchor against the live text;
|
||||||
|
record the span's attribution as **landed** (provenance from the proposal's
|
||||||
|
`author`, word-precise per INV-40 for block granularity); **advance the F6
|
||||||
|
baseline** for that region (the machine-landing the seam used to do on apply);
|
||||||
|
`removeProposal(id)`. No text is re-applied. If the writer edited the inserted
|
||||||
|
span, the *current* buffer text is what gets finalized (their edits become
|
||||||
|
layered human authorship).
|
||||||
|
- **`revertInPlace(id)`** — resolve the proposal's live span; replace it with the
|
||||||
|
stored original `Fingerprint.text`; `removeProposal(id)`. Reverts the whole
|
||||||
|
block regardless of any in-place edits.
|
||||||
|
- **`acceptAll()` / `rejectAll()`** — iterate the current document's pending
|
||||||
|
proposals in **descending** anchor order (so earlier resolutions don't shift
|
||||||
|
later offsets), skipping orphans with a tally (INV-42 shape). `acceptAll` reuses
|
||||||
|
the #46 batch ordering; **`rejectAll` is new** and symmetric.
|
||||||
|
|
||||||
|
Both surfaces (editor CodeLens/QuickPick and webview buttons/dropdown) call these
|
||||||
|
**same four methods** (INV-53). The webview's existing intent messages are renamed/
|
||||||
|
extended (`accept`/`reject`/`acceptAll` + new `rejectAll`) but stay intent-only
|
||||||
|
(INV-35).
|
||||||
|
|
||||||
|
### 3.4 Single diff source & render-once — INV-49 / INV-50
|
||||||
|
|
||||||
|
- **INV-49 (single diff source).** The per-proposal diff (block hunks → word/line
|
||||||
|
edit hunks) is produced by one pure function in `trackChangesModel.ts` (extends
|
||||||
|
the existing `diffToBlockHunks` / `wordEditHunks`, INV-39/40). The webview HTML
|
||||||
|
path and the editor decoration-plan path both consume it. Same proposal ⇒
|
||||||
|
identical diff in both surfaces (this is the testable parity guarantee).
|
||||||
|
- **INV-50 (render once).** With optimistic apply, the baseline→buffer delta now
|
||||||
|
*contains* the proposed change. The renderer must attribute any baseline→buffer
|
||||||
|
delta that is covered by a **pending proposal** to that proposal (rendered as a
|
||||||
|
proposal block), and must **not** also render it as an already-landed
|
||||||
|
machine-diff. Mechanism: the webview render reconciles baseline-diff spans
|
||||||
|
against pending-proposal anchors; a span covered by a pending proposal is
|
||||||
|
rendered exactly once, as the proposal. *(This reconciliation is the primary
|
||||||
|
implementation risk; the implementation plan owns the exact span-mapping, but the
|
||||||
|
invariant — rendered once — is fixed here.)*
|
||||||
|
|
||||||
|
### 3.5 Editor decorations & CodeLens (the editor surface)
|
||||||
|
|
||||||
|
`EditorProposalController` (new, `vscode` host module) owns:
|
||||||
|
|
||||||
|
- **Two `TextEditorDecorationType`s** — an *insertion tint* (green/blue background,
|
||||||
|
by provenance) over real buffer ranges, and a *deletion hint* (a
|
||||||
|
`before`/`after` render-option carrying the struck original text, red,
|
||||||
|
strikethrough, non-editable). Both are recomputed from the decoration plan on
|
||||||
|
every `onDidChangeProposals` and on active-editor change.
|
||||||
|
- **A `CodeLensProvider`** — for each pending proposal whose anchor resolves in the
|
||||||
|
active document, emit two lenses positioned above the block: `Accept ▾`
|
||||||
|
(command `cowriting.proposalAcceptMenu` with the proposal id) and `Reject ▾`
|
||||||
|
(`cowriting.proposalRejectMenu`). Each command opens a `vscode.window
|
||||||
|
.showQuickPick(["… this proposal","… ALL proposals"])` and dispatches to
|
||||||
|
`finalizeInPlace`/`acceptAll` or `revertInPlace`/`rejectAll`.
|
||||||
|
- **Lifecycle wiring** — subscribes to `proposals.onDidChangeProposals` (the same
|
||||||
|
event the preview uses) and `window.onDidChangeActiveTextEditor`; clears its
|
||||||
|
decorations + lenses for documents with no pending proposals (so a clean editor
|
||||||
|
stays clean — INV-32's spirit holds *when there is nothing pending*).
|
||||||
|
|
||||||
|
Deletion hints are **decoration-only** (never buffer text, never saved) and
|
||||||
|
insertion tints decorate **real** buffer text (INV-52).
|
||||||
|
|
||||||
|
### 3.6 Webview controls (label + dropdown parity)
|
||||||
|
|
||||||
|
`proposalBlockHtml` (`trackChangesModel.ts`) swaps the ✓/✗ glyph buttons for an
|
||||||
|
**`Accept ▾`** / **`Reject ▾`** control pair. The `▾` opens a small CSS/JS dropdown
|
||||||
|
in the sealed webview (`preview.ts`/`preview.css`, no network — INV-21) offering
|
||||||
|
*Accept all* / *Reject all*; selecting posts the corresponding intent
|
||||||
|
(`acceptAll` / `rejectAll`) or the per-proposal `accept`/`reject`. The host routes
|
||||||
|
all four to the controller methods of §3.3 (INV-35 preserved).
|
||||||
|
|
||||||
|
### 3.7 Components & files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/editorProposalController.ts` | **NEW** — optimistic apply, decoration types, deletion-hint injection, `CodeLensProvider`, QuickPick menus; subscribes to `onDidChangeProposals` + active-editor change. |
|
||||||
|
| `src/proposalController.ts` | Add `finalizeInPlace(id)`, `revertInPlace(id)`, `rejectAll(docKey)`; `propose()` delegates the optimistic buffer write (so the controller stays state-pure); accept/reject routing now finalizes/reverts in place (no seam re-apply). |
|
||||||
|
| `src/trackChangesModel.ts` | Factor the shared per-proposal diff into one pure producer feeding both surfaces; reconcile pending-proposal spans in `renderReview` (INV-50); swap ✓/✗ → `Accept ▾`/`Reject ▾` in `proposalBlockHtml`. |
|
||||||
|
| `src/trackChangesPreview.ts` | Route new `rejectAll` intent; keep `accept`/`reject`/`acceptAll`; relabel toolbar/proposal controls. |
|
||||||
|
| `src/preview.ts` / `preview.css` | Dropdown control for `Accept ▾`/`Reject ▾`; same diff CSS reused. |
|
||||||
|
| `src/extension.ts` | Register `EditorProposalController`, its CodeLens provider, and the menu commands; wire it alongside `ProposalController`/`TrackChangesPreviewController`. |
|
||||||
|
| `package.json` | Add `cowriting.rejectAllProposals`, `cowriting.proposalAcceptMenu`, `cowriting.proposalRejectMenu`; CodeLens contribution; markdown-gated `when` clauses. |
|
||||||
|
|
||||||
|
### 3.8 Invariants
|
||||||
|
|
||||||
|
- **INV-48 — Optimistic apply (with re-anchor).** On propose, the proposed text is
|
||||||
|
written into the live editor buffer (the buffer becomes the would-be-accepted
|
||||||
|
result); this write does **not** advance the F6 baseline and does **not** fire the
|
||||||
|
F4 machine-landing seam. Because F4's `fp.text` is the *original* target — which
|
||||||
|
leaves the buffer once the replacement lands — optimistic apply **stores the
|
||||||
|
pre-apply text on `Proposal.original` and re-fingerprints the anchor to the applied
|
||||||
|
text**, so `resolve()` keeps finding the proposal in the mutated buffer; finalize /
|
||||||
|
revert / decorate all key off the re-anchored fp, and revert restores `original`.
|
||||||
|
`original` is captured **exactly once** (first apply): a proposal that survives a
|
||||||
|
save+reload already carries it, so a fresh session never re-captures it from the
|
||||||
|
already-applied buffer (reload-safety, INV-51/54). **Reverses INV-10** (propose no
|
||||||
|
longer leaves the document untouched) and **INV-32** (the editor is no longer kept
|
||||||
|
unconditionally clean — it shows pending proposals).
|
||||||
|
- **INV-49 — Single diff source.** Editor decorations and webview HTML both derive
|
||||||
|
from one pure, deterministic per-proposal diff (extends `diffToBlockHunks` /
|
||||||
|
`wordEditHunks`, INV-22/39/40). The same proposal yields the same diff in both
|
||||||
|
surfaces.
|
||||||
|
- **INV-50 — Rendered once.** A span covered by a pending proposal is rendered
|
||||||
|
exactly once — as that proposal — never additionally as an already-landed
|
||||||
|
baseline diff.
|
||||||
|
- **INV-51 — Finalize / revert in place.** Accept finalizes the span already in the
|
||||||
|
buffer (record attribution, advance baseline, clear proposal) without
|
||||||
|
re-applying text; Reject reverts the block region to the stored
|
||||||
|
`Fingerprint.text`. No double-apply.
|
||||||
|
- **INV-52 — Decoration roles.** Deletion hints are decoration-only (non-editable
|
||||||
|
`before`/`after` content, never buffer text, never saved); insertion tints
|
||||||
|
decorate real, editable buffer text.
|
||||||
|
- **INV-53 — Control parity.** Accept / Reject / Accept-all / Reject-all are
|
||||||
|
reachable from **both** the editor (CodeLens + QuickPick) and the webview
|
||||||
|
(buttons + dropdown) and route to the same `ProposalController` methods; the ✓/✗
|
||||||
|
glyph controls are superseded by labelled `Accept ▾`/`Reject ▾`.
|
||||||
|
- **INV-54 — Save persists pending.** Saving while a proposal is pending persists
|
||||||
|
the proposed (accepted-result) text; "pending" denotes provisional attribution /
|
||||||
|
un-advanced baseline, not document divergence. (Decorations are not persisted, so
|
||||||
|
the saved file is clean text.)
|
||||||
|
|
||||||
|
### 3.9 Error & edge handling
|
||||||
|
|
||||||
|
- **Orphaned proposal** (anchor no longer resolves) — no editor decorations/lenses
|
||||||
|
for it; it still surfaces in the webview at end with a dashed border (INV-34);
|
||||||
|
accept-all/reject-all skip it with a tally (INV-42).
|
||||||
|
- **Writer edits the inserted span, then accepts** — current buffer text is
|
||||||
|
finalized (their edits layer as human authorship); **then rejects** — block
|
||||||
|
reverts to original regardless.
|
||||||
|
- **External / concurrent edit shifts geometry** — anchors re-resolve on
|
||||||
|
`onDidChangeProposals` (existing resolve-or-flag); decoration plan + lenses
|
||||||
|
recompute.
|
||||||
|
- **Multiple proposals from one turn** — all optimistically applied (buffer = full
|
||||||
|
proposed document); each block independently finalizable/revertible; descending
|
||||||
|
order on accept-all/reject-all keeps offsets valid.
|
||||||
|
- **Reject of an in-buffer span after partial accept of siblings** — each
|
||||||
|
proposal's revert uses its own live-resolved span, independent of siblings.
|
||||||
|
- **Non-authorable document** (read-only / not on disk) — controls disabled exactly
|
||||||
|
as today (`authorable` gating).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Testing & E2E
|
||||||
|
|
||||||
|
Per the §9 pipeline and `coauthoring-*` precedent (this is a VS Code extension —
|
||||||
|
**no flotilla/PPE**; the gate is unit + host-E2E green).
|
||||||
|
|
||||||
|
- **Unit (the core):**
|
||||||
|
- The shared per-proposal diff producer: same hunks → insertion/deletion
|
||||||
|
segments; parity assertion that the decoration plan and the webview HTML derive
|
||||||
|
identical add/del spans for the same proposal (INV-49).
|
||||||
|
- `renderReview` render-once reconciliation: a pending-proposal span is emitted
|
||||||
|
as a proposal block and **not** as a landed baseline diff (INV-50).
|
||||||
|
- Decoration-plan computation from `wordEditHunks(fp.text, replacement)`:
|
||||||
|
insertion ranges in buffer coords; deletion-hint positions (INV-52).
|
||||||
|
- **Host E2E:**
|
||||||
|
- Propose (via the injectable `editTurn` stub) → editor shows insertion tint +
|
||||||
|
deletion hint + `Accept ▾`/`Reject ▾` CodeLens; buffer equals the accepted
|
||||||
|
result (INV-48).
|
||||||
|
- Edit the inserted text → **finalizeInPlace** keeps the edited text; baseline
|
||||||
|
advanced; proposal cleared (INV-51).
|
||||||
|
- **revertInPlace** restores `Fingerprint.text`; proposal cleared.
|
||||||
|
- Accept / Reject / Accept-all / Reject-all from **both** the editor command path
|
||||||
|
and the webview intent path produce the same end state (INV-53); `rejectAll`
|
||||||
|
clears all pending and reverts all blocks.
|
||||||
|
- Save while a proposal is pending → file on disk has the proposed text; no
|
||||||
|
decoration markup in the saved bytes (INV-54).
|
||||||
|
- Webview/editor parity: same proposal renders the same diff in both (INV-49).
|
||||||
|
- **Manual smoke:** ask Claude to edit a paragraph; confirm the editor diff is
|
||||||
|
editable, the deletion hint reads correctly, both surfaces' controls resolve it,
|
||||||
|
and a clean editor returns once nothing is pending.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Delivery Plan (rollout — not a task list)
|
||||||
|
|
||||||
|
One increment, single design-then-build (#64). Ships through branch → PR → `main`;
|
||||||
|
no migration and no new persisted sidecar shape (the `Proposal` record is
|
||||||
|
unchanged). The implementation plan (downstream `wgl-planning-and-executing`
|
||||||
|
session) owns the Task breakdown; a natural cut:
|
||||||
|
|
||||||
|
1. **Shared diff + render-once** — factor the pure per-proposal diff producer;
|
||||||
|
reconcile pending spans in `renderReview` (INV-49/50) + unit tests.
|
||||||
|
2. **Accept/reject rework** — `finalizeInPlace` / `revertInPlace` / `rejectAll` on
|
||||||
|
`ProposalController` (no seam re-apply); unit tests.
|
||||||
|
3. **Editor surface** — `EditorProposalController`: optimistic apply, decorations,
|
||||||
|
deletion hints, CodeLens + QuickPick menus; wire in `extension.ts`.
|
||||||
|
4. **Control parity** — relabel ✓/✗ → `Accept ▾`/`Reject ▾` + dropdown in the
|
||||||
|
webview; route `rejectAll`; `package.json` commands/`when`.
|
||||||
|
5. **Host E2E + manual smoke** across both surfaces.
|
||||||
|
|
||||||
|
Reversible by reverting the PR (restores the clean-editor / ✓-✗ webview behavior).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Open Questions
|
||||||
|
|
||||||
|
- **OQ-1 — Provenance tint exactness.** Insertion tint uses green=human-origin /
|
||||||
|
blue=LLM by the proposal `author`; whether to additionally word-tint *within* a
|
||||||
|
block by the F3 attribution of edited-in-place text (vs a single block tint) can
|
||||||
|
follow the existing F9/F10 coloring; v1 tints the proposed insertion by the
|
||||||
|
proposal author. *(Not blocking.)*
|
||||||
|
- **OQ-2 (inherited)** — The F11 design (`#43`) remains un-graduated (lives in code
|
||||||
|
+ an issue draft); unrelated to #64 but noted in the shared lineage. Track
|
||||||
|
separately.
|
||||||
|
- **OQ-3 — Editor dropdown fidelity.** The editor uses a QuickPick to stand in for
|
||||||
|
the webview's true `▾` dropdown (CodeLens cannot render a caret menu inline); if
|
||||||
|
a more button-like affordance is wanted later, a webview-style overlay is a
|
||||||
|
possible increment. *(Accepted for v1.)*
|
||||||
@@ -249,7 +249,7 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
range: vscode.Range,
|
range: vscode.Range,
|
||||||
newText: string,
|
newText: string,
|
||||||
provenance: Provenance,
|
provenance: Provenance,
|
||||||
opts?: { expectedVersion?: number; turnId?: string },
|
opts?: { expectedVersion?: number; turnId?: string; landBaseline?: boolean },
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!this.isTracked(document)) return false;
|
if (!this.isTracked(document)) return false;
|
||||||
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
|
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
|
||||||
@@ -290,15 +290,25 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
"(host minimized differently?) — the edit may be mis-attributed (INV-9).",
|
"(host minimized differently?) — the edit may be mis-attributed (INV-9).",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (ok) {
|
if (ok && opts?.landBaseline !== false) {
|
||||||
// F6 (INV-18): a real machine landing — signal the baseline to advance so
|
// F6 (INV-18): a real machine landing — advance the baseline. F12 (INV-48)
|
||||||
// this text never shows as a change in the diff view. Fire regardless of
|
// suppresses this for optimistic apply: the proposed text is in the buffer
|
||||||
// attribution-match bookkeeping above; the landing happened either way.
|
// but the change stays PENDING (baseline at pre-proposal) until accept.
|
||||||
this.applyEmitter.fire({ document });
|
this.applyEmitter.fire({ document });
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
signalLanded(document: vscode.TextDocument): void {
|
||||||
|
if (this.isTracked(document)) this.applyEmitter.fire({ document });
|
||||||
|
}
|
||||||
|
|
||||||
// ---- PUC-4: persistence on save ----------------------------------------------------
|
// ---- PUC-4: persistence on save ----------------------------------------------------
|
||||||
|
|
||||||
private onDidSave(document: vscode.TextDocument): void {
|
private onDidSave(document: vscode.TextDocument): void {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* EditorProposalController — F12/#64 editor surface (spec coauthoring-inline-editor-diff
|
||||||
|
* §3.5). Reverses INV-32 for pending proposals: on `onDidChangeProposals` it
|
||||||
|
* (1) optimistically applies any not-yet-applied proposal into the active editor's
|
||||||
|
* buffer (ProposalController.optimisticApply, INV-48), (2) decorates each applied
|
||||||
|
* proposal — insertion tint over the proposed text + a non-editable struck-red hint
|
||||||
|
* for deletions (INV-52, decorationPlan/INV-49), and (3) provides a CodeLens
|
||||||
|
* `Accept ▾ / Reject ▾` above each block whose ▾ opens a QuickPick (this / all).
|
||||||
|
* Owns no proposal STATE — it is a view over ProposalController (which stays the
|
||||||
|
* pure F4 owner). A document with no pending proposals shows nothing (INV-32's
|
||||||
|
* spirit when nothing is pending).
|
||||||
|
*/
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import type { ProposalController } from "./proposalController";
|
||||||
|
import { decorationPlan } from "./trackChangesModel";
|
||||||
|
import { isAuthorable } from "./workspacePath";
|
||||||
|
|
||||||
|
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
|
||||||
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
|
private readonly insertionDeco = vscode.window.createTextEditorDecorationType({
|
||||||
|
backgroundColor: new vscode.ThemeColor("diffEditor.insertedTextBackground"),
|
||||||
|
});
|
||||||
|
private readonly deletionDeco = vscode.window.createTextEditorDecorationType({
|
||||||
|
// a non-editable struck-red hint injected AFTER the insertion (INV-52)
|
||||||
|
after: { color: new vscode.ThemeColor("gitDecoration.deletedResourceForeground") },
|
||||||
|
textDecoration: "none",
|
||||||
|
});
|
||||||
|
private readonly lensEmitter = new vscode.EventEmitter<void>();
|
||||||
|
readonly onDidChangeCodeLenses = this.lensEmitter.event;
|
||||||
|
/** Pending debounce timers — one per URI — coalesce rapid-fire propose events
|
||||||
|
* (e.g. runEditAndPropose's N sequential propose() calls) into a single
|
||||||
|
* optimistic-apply pass that runs after ALL proposals are created. Without this,
|
||||||
|
* each propose() fires onDidChangeProposals synchronously and the controller's
|
||||||
|
* optimisticApply runs concurrently with the still-in-progress propose loop,
|
||||||
|
* causing "file changed in the meantime" workspace-edit conflicts. */
|
||||||
|
private readonly pendingApply = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
constructor(private readonly proposals: ProposalController) {
|
||||||
|
this.disposables.push(
|
||||||
|
this.insertionDeco, this.deletionDeco, this.lensEmitter,
|
||||||
|
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
|
||||||
|
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
|
||||||
|
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
|
||||||
|
// 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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Debounce-schedule an optimistic-apply pass for the given URI. Multiple rapid
|
||||||
|
* onDidChangeProposals events (from a single runEditAndPropose batch) collapse
|
||||||
|
* into one pass that runs after the batch completes. */
|
||||||
|
private scheduleApply(uri: string): void {
|
||||||
|
const prev = this.pendingApply.get(uri);
|
||||||
|
if (prev !== undefined) clearTimeout(prev);
|
||||||
|
this.pendingApply.set(
|
||||||
|
uri,
|
||||||
|
setTimeout(() => {
|
||||||
|
this.pendingApply.delete(uri);
|
||||||
|
void this.onProposalsChanged(uri);
|
||||||
|
}, 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply any not-yet-applied proposals on the doc, then re-decorate + refresh lenses. */
|
||||||
|
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;
|
||||||
|
const key = this.proposals.keyFor(doc);
|
||||||
|
for (const v of this.proposals.listProposals(doc)) {
|
||||||
|
if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) {
|
||||||
|
await this.proposals.optimisticApply(doc, v.id); // fires onDidChangeProposals again; guarded by isApplied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
|
||||||
|
if (ed) this.renderEditor(ed);
|
||||||
|
this.lensEmitter.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decorate the editor for every applied proposal on its document (INV-52). */
|
||||||
|
private renderEditor(editor: vscode.TextEditor): void {
|
||||||
|
const doc = editor.document;
|
||||||
|
if (doc.languageId !== "markdown") {
|
||||||
|
editor.setDecorations(this.insertionDeco, []);
|
||||||
|
editor.setDecorations(this.deletionDeco, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = this.proposals.keyFor(doc);
|
||||||
|
const insertions: vscode.Range[] = [];
|
||||||
|
const deletions: vscode.DecorationOptions[] = [];
|
||||||
|
for (const v of this.proposals.listProposals(doc)) {
|
||||||
|
if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue;
|
||||||
|
const plan = decorationPlan(v.anchorStart, v.original, v.replacement);
|
||||||
|
for (const ins of plan.insertions) {
|
||||||
|
insertions.push(new vscode.Range(doc.positionAt(ins.start), doc.positionAt(ins.end)));
|
||||||
|
}
|
||||||
|
for (const del of plan.deletions) {
|
||||||
|
deletions.push({
|
||||||
|
range: new vscode.Range(doc.positionAt(del.at), doc.positionAt(del.at)),
|
||||||
|
renderOptions: { after: { contentText: ` ${del.text} `, textDecoration: "line-through" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editor.setDecorations(this.insertionDeco, insertions);
|
||||||
|
editor.setDecorations(this.deletionDeco, deletions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */
|
||||||
|
provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
|
||||||
|
if (document.languageId !== "markdown") return [];
|
||||||
|
const key = this.proposals.keyFor(document);
|
||||||
|
const lenses: vscode.CodeLens[] = [];
|
||||||
|
for (const v of this.proposals.listProposals(document)) {
|
||||||
|
if (v.anchorStart === null || !this.proposals.isApplied(key, v.id)) continue;
|
||||||
|
const pos = document.positionAt(v.anchorStart);
|
||||||
|
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] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lenses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dropdown: this-proposal vs all-proposals, then dispatch. */
|
||||||
|
private async menu(kind: "accept" | "reject", id?: string): Promise<void> {
|
||||||
|
const doc = vscode.window.activeTextEditor?.document;
|
||||||
|
if (!doc || !id) return;
|
||||||
|
const key = this.proposals.keyFor(doc);
|
||||||
|
const verb = kind === "accept" ? "Accept" : "Reject";
|
||||||
|
const pick = await vscode.window.showQuickPick(
|
||||||
|
[`${verb} this proposal`, `${verb} ALL proposals`],
|
||||||
|
{ placeHolder: `${verb} Claude's proposal` },
|
||||||
|
);
|
||||||
|
if (!pick) return;
|
||||||
|
const all = pick.includes("ALL");
|
||||||
|
if (kind === "accept") {
|
||||||
|
if (all) await this.proposals.acceptAllProposals(doc);
|
||||||
|
else await this.proposals.finalizeInPlace(key, id);
|
||||||
|
} else {
|
||||||
|
if (all) await this.proposals.rejectAll(doc);
|
||||||
|
else await this.proposals.revertInPlace(key, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const t of this.pendingApply.values()) clearTimeout(t);
|
||||||
|
this.pendingApply.clear();
|
||||||
|
for (const d of this.disposables) d.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { SidecarRouter } from "./sidecarRouter";
|
|||||||
import { DiffViewController } from "./diffViewController";
|
import { DiffViewController } from "./diffViewController";
|
||||||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||||||
import { LiveProgressUi } from "./liveProgressUi";
|
import { LiveProgressUi } from "./liveProgressUi";
|
||||||
|
import { EditorProposalController } from "./editorProposalController";
|
||||||
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
||||||
|
|
||||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||||
@@ -25,6 +26,7 @@ export interface CowritingApi {
|
|||||||
trackChangesPreviewController: TrackChangesPreviewController;
|
trackChangesPreviewController: TrackChangesPreviewController;
|
||||||
sidecarRouter: SidecarRouter;
|
sidecarRouter: SidecarRouter;
|
||||||
liveProgressUi: LiveProgressUi;
|
liveProgressUi: LiveProgressUi;
|
||||||
|
editorProposalController: EditorProposalController;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||||
@@ -115,6 +117,11 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
);
|
);
|
||||||
context.subscriptions.push(trackChangesPreviewController);
|
context.subscriptions.push(trackChangesPreviewController);
|
||||||
|
|
||||||
|
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
|
||||||
|
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
|
||||||
|
const editorProposalController = new EditorProposalController(proposalController);
|
||||||
|
context.subscriptions.push(editorProposalController);
|
||||||
|
|
||||||
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
|
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
|
||||||
// (also reachable from the preview toolbar's "Accept all" button). Reuses the
|
// (also reachable from the preview toolbar's "Accept all" button). Reuses the
|
||||||
// batched F4 seam + reports applied-vs-skipped.
|
// batched F4 seam + reports applied-vs-skipped.
|
||||||
@@ -129,6 +136,18 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// #64 (INV-53): reject every pending proposal on the active doc in one gesture.
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => {
|
||||||
|
const doc = vscode.window.activeTextEditor?.document;
|
||||||
|
if (!doc || doc.languageId !== "markdown") {
|
||||||
|
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await trackChangesPreviewController.rejectAll(doc);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// --- F6 machine-landing wiring — now for ANY authorable doc ---
|
// --- F6 machine-landing wiring — now for ANY authorable doc ---
|
||||||
// The seam's single machine-landing signal advances the F6 baseline (INV-18);
|
// 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.
|
// the seam can now fire on out-of-folder files too, so wire it unconditionally.
|
||||||
@@ -342,6 +361,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
trackChangesPreviewController,
|
trackChangesPreviewController,
|
||||||
sidecarRouter,
|
sidecarRouter,
|
||||||
liveProgressUi,
|
liveProgressUi,
|
||||||
|
editorProposalController,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ export interface Proposal {
|
|||||||
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
|
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
|
||||||
*/
|
*/
|
||||||
granularity?: "block" | "single";
|
granularity?: "block" | "single";
|
||||||
|
/**
|
||||||
|
* F12/#64 (INV-48): the pre-apply text, captured when a proposal is
|
||||||
|
* optimistically applied to the buffer. `replacement` is now in the buffer and
|
||||||
|
* `fp.text` re-anchors to it, so `original` is the only record of what to revert
|
||||||
|
* to (revert-in-place) and what to show struck in the `<del>` half. Absent on a
|
||||||
|
* proposal created but not yet optimistically applied (or older sidecars).
|
||||||
|
*/
|
||||||
|
original?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Artifact {
|
export interface Artifact {
|
||||||
@@ -252,6 +260,8 @@ export function serializeArtifact(a: Artifact): string {
|
|||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
|
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
|
||||||
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
|
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
|
||||||
|
...(p.original !== undefined ? { original: p.original } : {}),
|
||||||
|
...(p.granularity !== undefined ? { granularity: p.granularity } : {}),
|
||||||
},
|
},
|
||||||
p,
|
p,
|
||||||
),
|
),
|
||||||
|
|||||||
+191
-4
@@ -13,8 +13,8 @@
|
|||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { SidecarRouter, docIdentity } from "./sidecarRouter";
|
import { SidecarRouter, docIdentity } from "./sidecarRouter";
|
||||||
import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model";
|
import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model";
|
||||||
import { resolve, shift, type OffsetRange } from "./anchorer";
|
import { resolve, shift, buildFingerprint, type OffsetRange } from "./anchorer";
|
||||||
import { addProposal, removeProposal } from "./proposalModel";
|
import { addProposal, removeProposal, setProposalApplied } from "./proposalModel";
|
||||||
import type { AttributionController } from "./attributionController";
|
import type { AttributionController } from "./attributionController";
|
||||||
import type { VersionGuard } from "./versionGuard";
|
import type { VersionGuard } from "./versionGuard";
|
||||||
import { isAuthorable } from "./workspacePath";
|
import { isAuthorable } from "./workspacePath";
|
||||||
@@ -39,6 +39,8 @@ interface DocState {
|
|||||||
live: Map<string, OffsetRange>;
|
live: Map<string, OffsetRange>;
|
||||||
/** proposal ids whose anchor did not resolve at last render (stale/orphaned). */
|
/** proposal ids whose anchor did not resolve at last render (stale/orphaned). */
|
||||||
unresolved: Set<string>;
|
unresolved: Set<string>;
|
||||||
|
/** ids already optimistically applied to the buffer (so the trigger doesn't re-apply). */
|
||||||
|
applied: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProposalController implements vscode.Disposable {
|
export class ProposalController implements vscode.Disposable {
|
||||||
@@ -89,8 +91,9 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
anchorStart: resolved === "orphaned" ? null : resolved.start,
|
anchorStart: resolved === "orphaned" ? null : resolved.start,
|
||||||
anchorEnd: resolved === "orphaned" ? null : resolved.end,
|
anchorEnd: resolved === "orphaned" ? null : resolved.end,
|
||||||
replaced: fp?.text ?? "",
|
replaced: p.original ?? fp?.text ?? "",
|
||||||
replacement: p.replacement,
|
replacement: p.replacement,
|
||||||
|
original: p.original,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -104,6 +107,7 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
artifact: this.store.load(docPath) ?? emptyArtifact(docPath),
|
artifact: this.store.load(docPath) ?? emptyArtifact(docPath),
|
||||||
live: new Map(),
|
live: new Map(),
|
||||||
unresolved: new Set(),
|
unresolved: new Set(),
|
||||||
|
applied: new Set(),
|
||||||
};
|
};
|
||||||
this.docs.set(docPath, state);
|
this.docs.set(docPath, state);
|
||||||
}
|
}
|
||||||
@@ -137,8 +141,33 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
|
|
||||||
// ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ----------------------------------
|
// ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ----------------------------------
|
||||||
|
|
||||||
/** Accept by proposal id (test-facing twin of the thread-menu gesture). */
|
/** Accept by id — F12: finalize the already-applied text in place (INV-51). */
|
||||||
async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
|
async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
|
||||||
|
if (this.isApplied(docPath, proposalId)) {
|
||||||
|
// INV-11 (applied path): if an external write mangled the optimistically-applied
|
||||||
|
// text so the fingerprint no longer resolves, refuse finalize — same guard as the
|
||||||
|
// legacy accept path. Direct finalizeInPlace calls (CodeLens Accept gesture where
|
||||||
|
// the user may have edited inside the applied span) bypass this check intentionally.
|
||||||
|
const hit = this.byId(docPath, proposalId);
|
||||||
|
if (hit) {
|
||||||
|
const document = this.openDoc(hit.state);
|
||||||
|
if (document) {
|
||||||
|
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
|
||||||
|
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
|
||||||
|
if (resolved === "orphaned") {
|
||||||
|
if (!opts?.silent) {
|
||||||
|
void vscode.window.showWarningMessage(
|
||||||
|
"Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.finalizeInPlace(docPath, proposalId);
|
||||||
|
}
|
||||||
|
// Fallback: a proposal that was never optimistically applied (e.g. orphaned at
|
||||||
|
// apply time) keeps the legacy seam-apply accept.
|
||||||
const hit = this.byId(docPath, proposalId);
|
const hit = this.byId(docPath, proposalId);
|
||||||
return hit ? this.accept(hit.state, hit.proposal, opts) : false;
|
return hit ? this.accept(hit.state, hit.proposal, opts) : false;
|
||||||
}
|
}
|
||||||
@@ -181,6 +210,12 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reject by id — F12: revert the applied text in place (INV-51). */
|
||||||
|
async rejectByIdInPlace(docPath: string, proposalId: string): Promise<boolean> {
|
||||||
|
if (this.isApplied(docPath, proposalId)) return this.revertInPlace(docPath, proposalId);
|
||||||
|
return this.rejectById(docPath, proposalId);
|
||||||
|
}
|
||||||
|
|
||||||
private async accept(state: DocState, proposal: Proposal, opts?: { silent?: boolean }): Promise<boolean> {
|
private async accept(state: DocState, proposal: Proposal, opts?: { silent?: boolean }): Promise<boolean> {
|
||||||
if (this.guard.isReadOnly(state.docPath)) return false;
|
if (this.guard.isReadOnly(state.docPath)) return false;
|
||||||
const document = this.openDoc(state);
|
const document = this.openDoc(state);
|
||||||
@@ -257,6 +292,158 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True once this proposal's text is in the buffer (optimistic apply ran). */
|
||||||
|
isApplied(docPath: string, proposalId: string): boolean {
|
||||||
|
return this.docs.get(docPath)?.applied.has(proposalId) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* word-precise seam (block → per-word hunks, INV-40; single → whole range) but
|
||||||
|
* with `landBaseline:false` (the change stays pending). Then re-anchors the
|
||||||
|
* proposal to the applied text and stores the original (`setProposalApplied`), so
|
||||||
|
* `resolve()` finds it in the mutated buffer and revert/decorate key off it.
|
||||||
|
* Idempotent: a no-op if already applied.
|
||||||
|
*/
|
||||||
|
async optimisticApply(document: vscode.TextDocument, proposalId: string): Promise<boolean> {
|
||||||
|
if (!this.isTracked(document) || this.guard.isReadOnly(this.keyOf(document))) return false;
|
||||||
|
const docPath = this.keyOf(document);
|
||||||
|
const state = this.ensureState(document);
|
||||||
|
if (state.applied.has(proposalId)) return true;
|
||||||
|
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
|
||||||
|
const proposal = state.artifact.proposals.find((p) => p.id === proposalId);
|
||||||
|
const fp = proposal ? state.artifact.anchors[proposal.anchorId]?.fingerprint : undefined;
|
||||||
|
if (!proposal || !fp) return false;
|
||||||
|
// Reload-safety (INV-51/54): a proposal that already carries `original` was
|
||||||
|
// optimistically applied in a PRIOR session — the buffer holds the applied text
|
||||||
|
// and `fp` points at it, but this (fresh) controller's in-memory `applied` set is
|
||||||
|
// empty. Re-applying would recapture `original` from the already-applied buffer
|
||||||
|
// (= the replacement) and CLOBBER the true revert target, breaking Reject. Mark it
|
||||||
|
// applied in memory and stop — `original` is captured exactly once, on first apply.
|
||||||
|
if (proposal.original !== undefined) {
|
||||||
|
state.applied.add(proposalId);
|
||||||
|
this.renderAll(document);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const resolved = resolve(document.getText(), fp);
|
||||||
|
if (resolved === "orphaned") return false;
|
||||||
|
const original = document.getText(
|
||||||
|
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
|
||||||
|
);
|
||||||
|
const ok =
|
||||||
|
proposal.granularity === "block"
|
||||||
|
? await this.applyBlockOptimistic(document, resolved, proposal)
|
||||||
|
: await this.attribution.applyAgentEdit(
|
||||||
|
document,
|
||||||
|
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
|
||||||
|
proposal.replacement,
|
||||||
|
proposal.author,
|
||||||
|
{ expectedVersion: document.version, turnId: proposal.turnId, landBaseline: false },
|
||||||
|
);
|
||||||
|
if (!ok) return false;
|
||||||
|
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;
|
||||||
|
const appliedFp = buildFingerprint(document.getText(), { start: appliedStart, end: appliedEnd });
|
||||||
|
this.store.update(docPath, (a) => setProposalApplied(a, proposalId, appliedFp, original));
|
||||||
|
this.renderAll(document);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block optimistic apply: the INV-40 per-word hunks, but landBaseline:false. */
|
||||||
|
private async applyBlockOptimistic(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
resolved: OffsetRange,
|
||||||
|
proposal: Proposal,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const blockText = document.getText(
|
||||||
|
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
|
||||||
|
);
|
||||||
|
const subHunks = wordEditHunks(blockText, proposal.replacement);
|
||||||
|
if (subHunks.length === 0) return true;
|
||||||
|
for (const h of [...subHunks].sort((a, b) => b.start - a.start)) {
|
||||||
|
const range = new vscode.Range(
|
||||||
|
document.positionAt(resolved.start + h.start),
|
||||||
|
document.positionAt(resolved.start + h.end),
|
||||||
|
);
|
||||||
|
const ok = await this.attribution.applyAgentEdit(document, range, h.replacement, proposal.author, {
|
||||||
|
expectedVersion: document.version, turnId: proposal.turnId, landBaseline: false,
|
||||||
|
});
|
||||||
|
if (!ok) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
async finalizeInPlace(docPath: string, proposalId: string): Promise<boolean> {
|
||||||
|
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);
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
async revertInPlace(docPath: string, proposalId: string): Promise<boolean> {
|
||||||
|
const hit = this.byId(docPath, proposalId);
|
||||||
|
if (!hit) return false;
|
||||||
|
const document = this.openDoc(hit.state);
|
||||||
|
if (!document) return false;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
this.store.update(docPath, (a) => removeProposal(a, proposalId));
|
||||||
|
hit.state.applied.delete(proposalId);
|
||||||
|
this.renderAll(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.
|
||||||
|
*/
|
||||||
|
async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number }> {
|
||||||
|
if (!this.isTracked(document)) return { reverted: 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";
|
||||||
|
return { id: p.id, start: r === "orphaned" ? -1 : r.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 };
|
||||||
|
}
|
||||||
|
|
||||||
private reject(state: DocState, proposal: Proposal): void {
|
private reject(state: DocState, proposal: Proposal): void {
|
||||||
if (this.guard.isReadOnly(state.docPath)) return;
|
if (this.guard.isReadOnly(state.docPath)) return;
|
||||||
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
|
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
|
||||||
|
|||||||
@@ -37,6 +37,25 @@ export function removeProposal(artifact: Artifact, proposalId: string): boolean
|
|||||||
return artifact.proposals.length < before;
|
return artifact.proposals.length < before;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F12/#64 (INV-48): record a proposal as optimistically applied — store the
|
||||||
|
* pre-apply `original` (for revert + the struck `<del>`) and re-point its anchor
|
||||||
|
* fingerprint to the now-in-buffer applied text so `resolve()` finds it. Idempotent
|
||||||
|
* shape: a second call simply overwrites with the same values.
|
||||||
|
*/
|
||||||
|
export function setProposalApplied(
|
||||||
|
artifact: Artifact,
|
||||||
|
proposalId: string,
|
||||||
|
appliedFp: Fingerprint,
|
||||||
|
original: string,
|
||||||
|
): boolean {
|
||||||
|
const p = artifact.proposals.find((x) => x.id === proposalId);
|
||||||
|
if (!p) return false;
|
||||||
|
p.original = original;
|
||||||
|
artifact.anchors[p.anchorId] = { fingerprint: appliedFp };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Markdown comment body: instruction header + fenced whole-range diff. */
|
/** Markdown comment body: instruction header + fenced whole-range diff. */
|
||||||
export function proposalBody(targetText: string, p: Proposal): string {
|
export function proposalBody(targetText: string, p: Proposal): string {
|
||||||
const header = p.instruction ? `**Claude proposes** — _${p.instruction}_` : "**Claude proposes**";
|
const header = p.instruction ? `**Claude proposes** — _${p.instruction}_` : "**Claude proposes**";
|
||||||
|
|||||||
+102
-7
@@ -263,6 +263,42 @@ export function wordEditHunks(currentText: string, rewrittenText: string): EditH
|
|||||||
return hunks;
|
return hunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** F12/#64 (INV-49/52): the editor render of one optimistically-applied proposal. */
|
||||||
|
export interface DecorationPlan {
|
||||||
|
/** buffer ranges of inserted (proposed) text → tinted (INV-52). */
|
||||||
|
insertions: { start: number; end: number }[];
|
||||||
|
/** struck original text shown as a non-editable hint at a buffer offset (INV-52). */
|
||||||
|
deletions: { at: number; text: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F12/#64 (INV-49): compute the editor decoration plan for a proposal whose applied
|
||||||
|
* text occupies `[anchorStart, anchorStart+replacement.length)` in the buffer. The
|
||||||
|
* SAME word diff the webview uses (`wordEditHunks`) drives it, so both surfaces show
|
||||||
|
* the identical diff. A changed run maps to (a) an insertion range over the run's
|
||||||
|
* applied text and (b) a deletion hint carrying the run's removed original text at
|
||||||
|
* the run start; a pure insertion has no deletion hint; a pure deletion has only a
|
||||||
|
* hint. Pure, vscode-free, deterministic.
|
||||||
|
*/
|
||||||
|
export function decorationPlan(anchorStart: number, original: string, replacement: string): DecorationPlan {
|
||||||
|
const insertions: { start: number; end: number }[] = [];
|
||||||
|
const deletions: { at: number; text: string }[] = [];
|
||||||
|
// Walk the word diff once, tracking the applied-side offset as we consume parts.
|
||||||
|
let appliedOffset = anchorStart;
|
||||||
|
for (const part of diffWordsWithSpace(original, replacement)) {
|
||||||
|
if (part.added) {
|
||||||
|
insertions.push({ start: appliedOffset, end: appliedOffset + part.value.length });
|
||||||
|
appliedOffset += part.value.length;
|
||||||
|
} else if (part.removed) {
|
||||||
|
deletions.push({ at: appliedOffset, text: part.value });
|
||||||
|
// removed text is NOT in the applied buffer → appliedOffset does not advance
|
||||||
|
} else {
|
||||||
|
appliedOffset += part.value.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { insertions, deletions };
|
||||||
|
}
|
||||||
|
|
||||||
const isWs = (c: string): boolean => /\s/.test(c);
|
const isWs = (c: string): boolean => /\s/.test(c);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -701,6 +737,8 @@ export interface ProposalView {
|
|||||||
replaced: string;
|
replaced: string;
|
||||||
/** the proposed replacement text. */
|
/** the proposed replacement text. */
|
||||||
replacement: string;
|
replacement: string;
|
||||||
|
/** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */
|
||||||
|
original?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string {
|
function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string {
|
||||||
@@ -716,8 +754,14 @@ function proposalBlockHtml(p: ProposalView, render: (src: string) => string): st
|
|||||||
const after = `<ins class="cw-add">${safe(p.replacement)}</ins>`;
|
const after = `<ins class="cw-add">${safe(p.replacement)}</ins>`;
|
||||||
const actions =
|
const actions =
|
||||||
`<span class="cw-actions">` +
|
`<span class="cw-actions">` +
|
||||||
`<button class="cw-accept" data-action="accept">✓</button>` +
|
`<span class="cw-btngroup">` +
|
||||||
`<button class="cw-reject" data-action="reject">✗</button>` +
|
`<button class="cw-accept" data-action="accept">Accept</button>` +
|
||||||
|
`<button class="cw-caret" data-action="acceptAll" title="Accept all pending proposals">▾</button>` +
|
||||||
|
`</span>` +
|
||||||
|
`<span class="cw-btngroup">` +
|
||||||
|
`<button class="cw-reject" data-action="reject">Reject</button>` +
|
||||||
|
`<button class="cw-caret" data-action="rejectAll" title="Reject all pending proposals">▾</button>` +
|
||||||
|
`</span>` +
|
||||||
`</span>`;
|
`</span>`;
|
||||||
return `<div class="cw-proposal${unanchored}" data-proposal-id="${md.utils.escapeHtml(p.id)}">${actions}${before}${after}</div>`;
|
return `<div class="cw-proposal${unanchored}" data-proposal-id="${md.utils.escapeHtml(p.id)}">${actions}${before}${after}</div>`;
|
||||||
}
|
}
|
||||||
@@ -747,6 +791,26 @@ function renderReviewOp(
|
|||||||
* INV-34). Deterministic: proposals in the same block are ordered by anchorStart
|
* INV-34). Deterministic: proposals in the same block are ordered by anchorStart
|
||||||
* then id; trailing proposals keep input order.
|
* then id; trailing proposals keep input order.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* F12/#64 (INV-50): `currentText` with every resolved pending proposal's applied
|
||||||
|
* span reverted to its original (`replaced`) — the "landed" text the baseline diff
|
||||||
|
* should run against, so a pending proposal renders ONCE (as a proposal), never also
|
||||||
|
* as a landed change. Reverts high→low so earlier offsets stay valid. Pure. The
|
||||||
|
* preview's summary tally diffs against this too, so the toolbar count matches the
|
||||||
|
* body (a pending change is not double-counted as both a landed add/remove and a
|
||||||
|
* proposal).
|
||||||
|
*/
|
||||||
|
export function landedTextOf(currentText: string, proposals: ProposalView[]): string {
|
||||||
|
const pendingApplied = proposals
|
||||||
|
.filter((p) => p.anchorStart !== null)
|
||||||
|
.sort((a, b) => b.anchorStart! - a.anchorStart!);
|
||||||
|
let landedText = currentText;
|
||||||
|
for (const p of pendingApplied) {
|
||||||
|
landedText = landedText.slice(0, p.anchorStart!) + p.replaced + landedText.slice(p.anchorEnd!);
|
||||||
|
}
|
||||||
|
return landedText;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderReview(
|
export function renderReview(
|
||||||
baselineText: string,
|
baselineText: string,
|
||||||
currentText: string,
|
currentText: string,
|
||||||
@@ -755,8 +819,18 @@ export function renderReview(
|
|||||||
opts: RenderOptions = {},
|
opts: RenderOptions = {},
|
||||||
): string {
|
): string {
|
||||||
const render = opts.render ?? defaultRender;
|
const render = opts.render ?? defaultRender;
|
||||||
const ranges = splitBlocksWithRanges(currentText);
|
|
||||||
const ops = diffBlocks(baselineText, currentText);
|
// F12/#64 (INV-50): with optimistic apply the proposed text is already in
|
||||||
|
// `currentText`, so a naive baseline→current diff would render each proposed
|
||||||
|
// change BOTH as a landed diff and as its proposal block. Diff against the
|
||||||
|
// "landed" text (current minus pending proposals) so they render once.
|
||||||
|
const pendingApplied = proposals
|
||||||
|
.filter((p) => p.anchorStart !== null)
|
||||||
|
.sort((a, b) => b.anchorStart! - a.anchorStart!);
|
||||||
|
const landedText = landedTextOf(currentText, proposals);
|
||||||
|
|
||||||
|
const ranges = splitBlocksWithRanges(landedText);
|
||||||
|
const ops = diffBlocks(baselineText, landedText);
|
||||||
// #48: right after a PIN (baseline reason "pinned") with no changes since, the
|
// #48: right after a PIN (baseline reason "pinned") with no changes since, the
|
||||||
// panel is fully clean: no change marks (already absent) AND no authorship
|
// panel is fully clean: no change marks (already absent) AND no authorship
|
||||||
// coloring, so the pin reads as "this is my clean starting point". Skip
|
// coloring, so the pin reads as "this is my clean starting point". Skip
|
||||||
@@ -766,7 +840,28 @@ export function renderReview(
|
|||||||
// its authorship coloring (F10 INV-33), so this is gated on the pin specifically.
|
// its authorship coloring (F10 INV-33), so this is gated on the pin specifically.
|
||||||
const clean = opts.pinned === true && ops.every((o) => o.kind === "unchanged");
|
const clean = opts.pinned === true && ops.every((o) => o.kind === "unchanged");
|
||||||
|
|
||||||
// Associate each resolved proposal with the current-side block index whose range
|
// Map a currentText offset to its landedText offset (account for reverted spans
|
||||||
|
// that precede it; reverts were applied high→low so the cumulative delta is stable).
|
||||||
|
const toLanded = (curOff: number): number => {
|
||||||
|
let delta = 0;
|
||||||
|
for (const p of [...pendingApplied].sort((a, b) => a.anchorStart! - b.anchorStart!)) {
|
||||||
|
if (p.anchorStart! < curOff) delta += p.replaced.length - (p.anchorEnd! - p.anchorStart!);
|
||||||
|
}
|
||||||
|
return curOff + delta;
|
||||||
|
};
|
||||||
|
|
||||||
|
// F12/#64 (INV-49/50): blocks are split from `landedText`, so `blk.start` is a
|
||||||
|
// landedText offset — but `authorSpans` arrive in `currentText` coordinates. A
|
||||||
|
// colored block sitting AFTER a length-changing pending proposal would otherwise
|
||||||
|
// be mis-colored (the offsets diverge by the revert delta). Map the spans into
|
||||||
|
// landedText coordinates once so authorship coloring stays aligned.
|
||||||
|
const landedSpans: AuthorSpan[] = authorSpans.map((s) => ({
|
||||||
|
...s,
|
||||||
|
start: toLanded(s.start),
|
||||||
|
end: toLanded(s.end),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Associate each resolved proposal with the landedText block index whose range
|
||||||
// it anchors into: the largest block with start <= anchorStart (the containing
|
// it anchors into: the largest block with start <= anchorStart (the containing
|
||||||
// block, or the nearest preceding block when the anchor sits in a gap). A
|
// block, or the nearest preceding block when the anchor sits in a gap). A
|
||||||
// resolved anchor before all blocks, and every unresolved proposal, trails.
|
// resolved anchor before all blocks, and every unresolved proposal, trails.
|
||||||
@@ -778,7 +873,7 @@ export function renderReview(
|
|||||||
const byBlock = new Map<number, ProposalView[]>();
|
const byBlock = new Map<number, ProposalView[]>();
|
||||||
const trailing: ProposalView[] = [];
|
const trailing: ProposalView[] = [];
|
||||||
for (const p of proposals) {
|
for (const p of proposals) {
|
||||||
const j = p.anchorStart === null ? -1 : blockOf(p.anchorStart);
|
const j = p.anchorStart === null ? -1 : blockOf(toLanded(p.anchorStart));
|
||||||
if (j < 0) {
|
if (j < 0) {
|
||||||
trailing.push(p);
|
trailing.push(p);
|
||||||
continue;
|
continue;
|
||||||
@@ -795,7 +890,7 @@ export function renderReview(
|
|||||||
const blockIndex = op.kind === "removed" ? -1 : ci;
|
const blockIndex = op.kind === "removed" ? -1 : ci;
|
||||||
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
||||||
const colored = (raw: string): string =>
|
const colored = (raw: string): string =>
|
||||||
blk && !clean ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
|
blk && !clean ? colorByAuthor(raw, blk.start, landedSpans, render) : render(raw);
|
||||||
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
|
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
|
||||||
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
||||||
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import * as vscode from "vscode";
|
|||||||
import type { DiffViewController } from "./diffViewController";
|
import type { DiffViewController } from "./diffViewController";
|
||||||
import type { AttributionController } from "./attributionController";
|
import type { AttributionController } from "./attributionController";
|
||||||
import type { ProposalController } from "./proposalController";
|
import type { ProposalController } from "./proposalController";
|
||||||
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
|
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, landedTextOf, type BlockOp } from "./trackChangesModel";
|
||||||
import { buildFingerprint } from "./anchorer";
|
import { buildFingerprint } from "./anchorer";
|
||||||
import { isAuthorable } from "./workspacePath";
|
import { isAuthorable } from "./workspacePath";
|
||||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||||
@@ -44,7 +44,8 @@ type ToolbarMsg =
|
|||||||
| { type: "pinBaseline" }
|
| { type: "pinBaseline" }
|
||||||
| { type: "askClaude"; scope: "document" }
|
| { type: "askClaude"; scope: "document" }
|
||||||
| { type: "askClaude"; scope: "selection"; start: number; end: number }
|
| { type: "askClaude"; scope: "selection"; start: number; end: number }
|
||||||
| { type: "acceptAll" };
|
| { type: "acceptAll" }
|
||||||
|
| { type: "rejectAll" };
|
||||||
|
|
||||||
export class TrackChangesPreviewController implements vscode.Disposable {
|
export class TrackChangesPreviewController implements vscode.Disposable {
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
@@ -196,8 +197,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
.acceptById(this.proposals.keyFor(document), m.proposalId)
|
.acceptById(this.proposals.keyFor(document), m.proposalId)
|
||||||
.then(() => this.refresh(document));
|
.then(() => this.refresh(document));
|
||||||
} else if (m?.type === "reject" && m.proposalId) {
|
} else if (m?.type === "reject" && m.proposalId) {
|
||||||
this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId);
|
void this.proposals.rejectByIdInPlace(this.proposals.keyFor(document), m.proposalId).then(() => this.refresh(document));
|
||||||
this.refresh(document);
|
|
||||||
} else if (m?.type === "pinBaseline") {
|
} else if (m?.type === "pinBaseline") {
|
||||||
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
|
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
|
||||||
this.diffView.pin(document);
|
this.diffView.pin(document);
|
||||||
@@ -208,6 +208,8 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
} else if (m?.type === "acceptAll") {
|
} else if (m?.type === "acceptAll") {
|
||||||
// #46 (INV-42): batch-accept every pending proposal on this doc, then report.
|
// #46 (INV-42): batch-accept every pending proposal on this doc, then report.
|
||||||
void this.acceptAll(document);
|
void this.acceptAll(document);
|
||||||
|
} else if (m?.type === "rejectAll") {
|
||||||
|
void this.rejectAll(document);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +230,17 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** #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
|
* 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
|
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
||||||
@@ -359,9 +372,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
const spans = this.attribution.spansFor(document);
|
const spans = this.attribution.spansFor(document);
|
||||||
const proposals = this.proposals.listProposals(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 = {
|
const summary = {
|
||||||
added: ops.filter((o) => o.kind === "added").length,
|
added: landedOps.filter((o) => o.kind === "added").length,
|
||||||
removed: ops.filter((o) => o.kind === "removed").length,
|
removed: landedOps.filter((o) => o.kind === "removed").length,
|
||||||
proposals: proposals.length,
|
proposals: proposals.length,
|
||||||
};
|
};
|
||||||
void panel.webview.postMessage({
|
void panel.webview.postMessage({
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { decorationPlan } from "../src/trackChangesModel";
|
||||||
|
|
||||||
|
describe("decorationPlan (INV-49)", () => {
|
||||||
|
test("derives insertion ranges + deletion hints from original→replacement", () => {
|
||||||
|
// anchorStart 10; original 'the brown fox' → 'the red fox'
|
||||||
|
const plan = decorationPlan(10, "the brown fox", "the red fox");
|
||||||
|
// one changed run: 'brown ' → 'red ' (word-level); insertion 'red ' tinted,
|
||||||
|
// deletion hint 'brown ' shown at the run start.
|
||||||
|
expect(plan.insertions.length).toBeGreaterThan(0);
|
||||||
|
expect(plan.deletions.length).toBeGreaterThan(0);
|
||||||
|
// insertion offsets are in buffer coords (>= anchorStart) and lie within the applied text
|
||||||
|
for (const ins of plan.insertions) {
|
||||||
|
expect(ins.start).toBeGreaterThanOrEqual(10);
|
||||||
|
expect(ins.end).toBeLessThanOrEqual(10 + "the red fox".length);
|
||||||
|
}
|
||||||
|
// a deletion hint carries the struck original text at a buffer offset
|
||||||
|
expect(plan.deletions[0].text).toContain("brown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pure insertion (no deletion) yields an insertion range and no deletion hint", () => {
|
||||||
|
// 'hello' → 'hello world': ' world' is a pure word-level insertion (no removed tokens)
|
||||||
|
const plan = decorationPlan(0, "hello", "hello world");
|
||||||
|
expect(plan.insertions.length).toBe(1);
|
||||||
|
expect(plan.deletions.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+12
-2
@@ -13,11 +13,20 @@ async function main(): Promise<void> {
|
|||||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-e2e-"));
|
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-e2e-"));
|
||||||
fs.cpSync(fixture, workspace, { recursive: true });
|
fs.cpSync(fixture, workspace, { recursive: true });
|
||||||
|
|
||||||
|
// VS Code derives its IPC socket path from --user-data-dir. The default
|
||||||
|
// (`<projectRoot>/.vscode-test/user-data`) plus `/<version>-main.sock` can blow
|
||||||
|
// past macOS's ~103-char UNIX-socket limit (`listen EINVAL`) when the project
|
||||||
|
// lives at a long path (e.g. a git worktree). Pin the user-data dir to a SHORT
|
||||||
|
// root under /tmp so the socket path stays well under the limit. (os.tmpdir() on
|
||||||
|
// macOS is itself a long /var/folders/… path, so we use /tmp directly.)
|
||||||
|
const userDataRoot = process.platform === "win32" ? os.tmpdir() : "/tmp";
|
||||||
|
const userDataDir = fs.mkdtempSync(path.join(userDataRoot, "cwud-"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runTests({
|
await runTests({
|
||||||
extensionDevelopmentPath,
|
extensionDevelopmentPath,
|
||||||
extensionTestsPath,
|
extensionTestsPath,
|
||||||
launchArgs: [workspace, "--disable-extensions"],
|
launchArgs: [workspace, "--disable-extensions", "--user-data-dir", userDataDir],
|
||||||
extensionTestsEnv: { E2E_WORKSPACE: workspace },
|
extensionTestsEnv: { E2E_WORKSPACE: workspace },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,10 +35,11 @@ async function main(): Promise<void> {
|
|||||||
await runTests({
|
await runTests({
|
||||||
extensionDevelopmentPath,
|
extensionDevelopmentPath,
|
||||||
extensionTestsPath: path.resolve(__dirname, "./suite-no-workspace/index"),
|
extensionTestsPath: path.resolve(__dirname, "./suite-no-workspace/index"),
|
||||||
launchArgs: ["--disable-extensions"],
|
launchArgs: ["--disable-extensions", "--user-data-dir", userDataDir],
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,8 +109,9 @@ suite("F10 interactive review (host E2E — preview is the single review surface
|
|||||||
const pIdx = html.indexOf(`data-proposal-id="${id}"`);
|
const pIdx = html.indexOf(`data-proposal-id="${id}"`);
|
||||||
const t2Idx = html.indexOf("second claude target");
|
const t2Idx = html.indexOf("second claude target");
|
||||||
assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)");
|
assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)");
|
||||||
// INV-10: proposing never touches the document.
|
// F12 (INV-48): EditorProposalController auto-applies the proposal into the buffer;
|
||||||
assert.ok(doc.getText().includes(T1), "document unchanged by propose");
|
// 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 () => {
|
test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => {
|
||||||
@@ -131,17 +132,18 @@ suite("F10 interactive review (host E2E — preview is the single review surface
|
|||||||
assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)");
|
assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reject → the proposal vanishes and the document is untouched (PUC-5)", async () => {
|
test("reject → the proposal vanishes and the document is reverted (PUC-5)", async () => {
|
||||||
const { doc, key } = await reopen(DOC_REL);
|
const { doc, key } = await reopen(DOC_REL);
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const before = doc.getText();
|
const before = doc.getText();
|
||||||
const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2");
|
const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2");
|
||||||
await settle();
|
await settle();
|
||||||
assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending");
|
assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending");
|
||||||
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id2), true, "reject");
|
// F12: use rejectByIdInPlace to revert the optimistically-applied buffer edit.
|
||||||
|
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id2), "rejectByIdInPlace reverts + removes");
|
||||||
await settle();
|
await settle();
|
||||||
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
|
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
|
||||||
assert.strictEqual(doc.getText(), before, "document untouched by reject");
|
assert.strictEqual(doc.getText(), before, "document reverted to pre-propose state");
|
||||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||||
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview");
|
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -99,8 +99,9 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
|||||||
"The quick RED fox jumps over the lazy CAT.",
|
"The quick RED fox jumps over the lazy CAT.",
|
||||||
"the block proposal carries the whole rewritten paragraph",
|
"the block proposal carries the whole rewritten paragraph",
|
||||||
);
|
);
|
||||||
// INV-10: proposing never mutates the document.
|
// F12 (INV-48): EditorProposalController optimistically applies the proposed text,
|
||||||
assert.ok(doc.getText().includes("brown fox") && doc.getText().includes("lazy dog"), "document unchanged by propose");
|
// so after settle the buffer has the replacement. The proposal is still pending.
|
||||||
|
assert.ok(doc.getText().includes("RED fox") && doc.getText().includes("lazy CAT"), "F12 optimistic-apply: proposed text in buffer");
|
||||||
void key;
|
void key;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,7 +130,9 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
|||||||
assert.ok(view, "the proposal is live");
|
assert.ok(view, "the proposal is live");
|
||||||
assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement");
|
assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement");
|
||||||
assert.strictEqual(view!.replaced, target, "replaces exactly the selected range");
|
assert.strictEqual(view!.replaced, target, "replaces exactly the selected range");
|
||||||
assert.ok(doc.getText().includes(target), "document unchanged by propose (INV-10)");
|
// F12 (INV-48): the EditorProposalController optimistically applies, so the buffer
|
||||||
|
// now has the replacement. `view.replaced` still records the original target text.
|
||||||
|
assert.ok(doc.getText().includes("The REWRITTEN paragraph from Claude."), "F12 optimistic-apply: proposed text in buffer");
|
||||||
void key;
|
void key;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
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 { addProposal, setProposalApplied } from "../../../src/proposalModel";
|
||||||
|
import { buildFingerprint } from "../../../src/anchorer";
|
||||||
|
|
||||||
|
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 controllers");
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() };
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
doc,
|
||||||
|
new vscode.Range(doc.positionAt(start), doc.positionAt(start + "alpha".length)),
|
||||||
|
"ALPHA",
|
||||||
|
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } },
|
||||||
|
{ landBaseline: false },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
assert.ok(ok, "edit applied");
|
||||||
|
assert.ok(doc.getText().includes("ALPHA"), "text in buffer");
|
||||||
|
assert.strictEqual(api.diffViewController.getBaseline(key)?.text ?? doc.getText(), before, "baseline unchanged");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => {
|
||||||
|
// Optimistic apply lands the text + re-anchors; the buffer becomes the accepted result.
|
||||||
|
test("optimisticApply puts the proposed text in the buffer and re-anchors", async () => {
|
||||||
|
const { doc } = await freshDoc("docs/f12-opt.md", "# T\n\nReplace alpha please.\n");
|
||||||
|
const api = await getApi();
|
||||||
|
const p = api.proposalController;
|
||||||
|
const fp = { text: "Replace alpha please.", before: "", after: "", lineHint: 2 };
|
||||||
|
const id = await p.propose(doc, fp, "Replace ALPHA please.",
|
||||||
|
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
||||||
|
await api.proposalController.optimisticApply(doc, id!);
|
||||||
|
await settle();
|
||||||
|
assert.ok(doc.getText().includes("Replace ALPHA please."), "applied to buffer");
|
||||||
|
// re-anchored: the proposal still resolves against the mutated buffer
|
||||||
|
assert.strictEqual(p.listProposals(doc)[0].anchorStart !== null, true, "re-anchored, resolves");
|
||||||
|
assert.strictEqual(p.listProposals(doc)[0].original, "Replace alpha please.", "original stored");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finalizeInPlace clears the proposal and keeps the applied text (no double-apply)", async () => {
|
||||||
|
const { doc } = await freshDoc("docs/f12-fin.md", "# T\n\nKeep alpha now.\n");
|
||||||
|
const api = await getApi();
|
||||||
|
const p = api.proposalController;
|
||||||
|
const docPath = p.keyFor(doc);
|
||||||
|
const fp = { text: "Keep alpha now.", before: "", after: "", lineHint: 2 };
|
||||||
|
const id = await p.propose(doc, fp, "Keep ALPHA now.",
|
||||||
|
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
||||||
|
await p.optimisticApply(doc, id!);
|
||||||
|
await settle();
|
||||||
|
const ok = await p.finalizeInPlace(docPath, id!);
|
||||||
|
await settle();
|
||||||
|
assert.ok(ok, "finalized");
|
||||||
|
assert.strictEqual(doc.getText(), "# T\n\nKeep ALPHA now.\n", "applied text retained, no double-apply");
|
||||||
|
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("revertInPlace restores the original and clears the proposal", async () => {
|
||||||
|
const { doc } = await freshDoc("docs/f12-rev.md", "# T\n\nUndo alpha here.\n");
|
||||||
|
const api = await getApi();
|
||||||
|
const p = api.proposalController;
|
||||||
|
const docPath = p.keyFor(doc);
|
||||||
|
const fp = { text: "Undo alpha here.", before: "", after: "", lineHint: 2 };
|
||||||
|
const id = await p.propose(doc, fp, "Undo ALPHA here.",
|
||||||
|
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
||||||
|
await p.optimisticApply(doc, id!);
|
||||||
|
await settle();
|
||||||
|
const ok = await p.revertInPlace(docPath, id!);
|
||||||
|
await settle();
|
||||||
|
assert.ok(ok, "reverted");
|
||||||
|
assert.strictEqual(doc.getText(), "# T\n\nUndo alpha here.\n", "original restored");
|
||||||
|
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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");
|
||||||
|
await settle();
|
||||||
|
for (const id of ids) await p.optimisticApply(doc, id);
|
||||||
|
await settle();
|
||||||
|
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"), "both applied");
|
||||||
|
const { reverted } = await p.rejectAll(doc);
|
||||||
|
await settle();
|
||||||
|
assert.strictEqual(reverted, ids.length, "all reverted");
|
||||||
|
assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document restored");
|
||||||
|
assert.strictEqual(p.listProposals(doc).length, 0, "all cleared");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
await settle(); await settle();
|
||||||
|
assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
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];
|
||||||
|
assert.strictEqual(v.original, "The brown fox runs.", "original captured for the deletion hint/revert");
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
await settle(); await settle();
|
||||||
|
// human tweaks the inserted text
|
||||||
|
const at = doc.getText().indexOf("ALPHA");
|
||||||
|
const we = new vscode.WorkspaceEdit();
|
||||||
|
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 5)), "ALPHA!");
|
||||||
|
await vscode.workspace.applyEdit(we);
|
||||||
|
await settle();
|
||||||
|
// keyFor(doc) gives the repo-relative path that finalizeInPlace uses as its key;
|
||||||
|
// the `key` from freshDoc is the URI string, which would not match for in-workspace docs.
|
||||||
|
const docKey = api.proposalController.keyFor(doc);
|
||||||
|
await api.proposalController.finalizeInPlace(docKey, ids[0]);
|
||||||
|
await settle();
|
||||||
|
assert.ok(doc.getText().includes("ALPHA! word here."), "human edit preserved on accept");
|
||||||
|
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "cleared");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
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");
|
||||||
|
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] });
|
||||||
|
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" });
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cowriting.rejectAllProposals is registered + markdown-guarded", async () => {
|
||||||
|
const all = await vscode.commands.getCommands(true);
|
||||||
|
assert.ok(all.includes("cowriting.rejectAllProposals"));
|
||||||
|
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.rejectAllProposals",
|
||||||
|
);
|
||||||
|
assert.ok(entry && /editorLangId == markdown/.test(entry.when ?? ""), "palette-guarded on markdown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// already-applied buffer — doing so would clobber the true revert target and break
|
||||||
|
// Reject. (Regression for the final-review CRITICAL; INV-51/54.)
|
||||||
|
suite("F12 inline diff — reload-safety (#64, INV-51/54)", () => {
|
||||||
|
test("optimisticApply does not clobber a previously-persisted original", async () => {
|
||||||
|
const TRUE_ORIGINAL = "The original sentence here.";
|
||||||
|
const APPLIED = "The APPLIED sentence here.";
|
||||||
|
const { doc } = await freshDoc("docs/f12-reload.md", `# R\n\n${TRUE_ORIGINAL}\n`);
|
||||||
|
const api = await getApi();
|
||||||
|
const p = api.proposalController;
|
||||||
|
const key = p.keyFor(doc);
|
||||||
|
|
||||||
|
// 1) The buffer holds the APPLIED text (as the saved-while-pending file would).
|
||||||
|
const at = doc.getText().indexOf(TRUE_ORIGINAL);
|
||||||
|
const we = new vscode.WorkspaceEdit();
|
||||||
|
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + TRUE_ORIGINAL.length)), APPLIED);
|
||||||
|
assert.ok(await vscode.workspace.applyEdit(we), "apply the prior-session applied text");
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// 2) Record the proposal directly in the sidecar exactly as a prior session left
|
||||||
|
// it: fp anchored to the APPLIED text + `original` = the TRUE original. We do
|
||||||
|
// NOT call optimisticApply, so this controller's in-memory `applied` stays empty.
|
||||||
|
const appliedAt = doc.getText().indexOf(APPLIED);
|
||||||
|
const appliedFp = buildFingerprint(doc.getText(), { start: appliedAt, end: appliedAt + APPLIED.length });
|
||||||
|
let id = "";
|
||||||
|
api.sidecarRouter.update(key, (a) => {
|
||||||
|
id = addProposal(a, appliedFp, APPLIED, { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }).proposalId;
|
||||||
|
setProposalApplied(a, id, appliedFp, TRUE_ORIGINAL);
|
||||||
|
});
|
||||||
|
p.renderAll(doc);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// 3) Re-entry as a reload would trigger (EditorProposalController re-applies on
|
||||||
|
// onDidChangeProposals because `applied` is empty). The guard must preserve original.
|
||||||
|
await p.optimisticApply(doc, id);
|
||||||
|
await settle();
|
||||||
|
const view = p.listProposals(doc).find((v) => v.id === id);
|
||||||
|
assert.ok(view, "proposal still present");
|
||||||
|
assert.strictEqual(view!.original, TRUE_ORIGINAL, "true original preserved, NOT clobbered with the applied text");
|
||||||
|
|
||||||
|
// 4) Reject restores the TRUE original (not the applied text).
|
||||||
|
assert.ok(await p.revertInPlace(key, id), "revert");
|
||||||
|
await settle();
|
||||||
|
assert.ok(doc.getText().includes(TRUE_ORIGINAL), "reject restored the true original");
|
||||||
|
assert.ok(!doc.getText().includes(APPLIED), "applied text removed on reject");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -98,8 +98,10 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
|||||||
0,
|
0,
|
||||||
"active doc A was NOT edited — editDocument honored the tab URI",
|
"active doc A was NOT edited — editDocument honored the tab URI",
|
||||||
);
|
);
|
||||||
// INV-10: proposing never mutates the document.
|
// F12 (INV-48): EditorProposalController optimistically applies proposals into the
|
||||||
assert.ok(b.doc.getText().includes("tab target paragraph"), "tab doc unchanged by propose");
|
// 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.
|
// No URI arg (palette / keybinding) → fall back to the active editor.
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ suite("#60 live turn progress (additive + cancel)", () => {
|
|||||||
assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events");
|
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]);
|
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]);
|
||||||
assert.ok(view, "the proposal is live");
|
assert.ok(view, "the proposal is live");
|
||||||
assert.ok(doc.getText().includes("Old paragraph."), "document unchanged by propose (INV-10)");
|
// F12 (INV-48): the EditorProposalController optimistically applies the proposed
|
||||||
|
// text into the buffer, so after settle the active-editor buffer shows the
|
||||||
|
// replacement. The proposal is still pending (not finalized) until Accept.
|
||||||
|
assert.ok(doc.getText().includes("New paragraph."), "F12 optimistic-apply: proposed text in buffer");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an aborted turn proposes nothing (INV-47)", async () => {
|
test("an aborted turn proposes nothing (INV-47)", async () => {
|
||||||
|
|||||||
@@ -63,13 +63,14 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
|||||||
const TARGET = "The propose target sentence lives here.";
|
const TARGET = "The propose target sentence lives here.";
|
||||||
const REPLACEMENT = "PROPOSED-BY-CLAUDE replacement sentence.";
|
const REPLACEMENT = "PROPOSED-BY-CLAUDE replacement sentence.";
|
||||||
|
|
||||||
test("propose records + renders a pending proposal and does NOT touch the document (INV-10)", async () => {
|
test("propose records a pending proposal; F12 optimistically applies it (INV-48, reverses INV-10)", async () => {
|
||||||
const doc = await openDoc();
|
const doc = await openDoc();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const before = doc.getText();
|
|
||||||
await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1");
|
await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1");
|
||||||
await settle();
|
await settle();
|
||||||
assert.strictEqual(doc.getText(), before, "document text unchanged (INV-10)");
|
// F12 (INV-48): EditorProposalController auto-applies the proposed text into the
|
||||||
|
// buffer, so after settle the buffer has the replacement text (INV-10 is reversed).
|
||||||
|
assert.ok(doc.getText().includes(REPLACEMENT), "F12 optimistic-apply: replacement in buffer after propose");
|
||||||
const rendered = api.proposalController.getRendered(DOC_REL);
|
const rendered = api.proposalController.getRendered(DOC_REL);
|
||||||
assert.strictEqual(rendered.length, 1);
|
assert.strictEqual(rendered.length, 1);
|
||||||
assert.strictEqual(rendered[0].pending, true);
|
assert.strictEqual(rendered[0].pending, true);
|
||||||
@@ -77,7 +78,8 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
|||||||
assert.strictEqual(rendered[0].canReply, false, "proposals are decide-only — no dead reply input (INV-12)");
|
assert.strictEqual(rendered[0].canReply, false, "proposals are decide-only — no dead reply input (INV-12)");
|
||||||
const art = readSidecar();
|
const art = readSidecar();
|
||||||
assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time");
|
assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time");
|
||||||
assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, TARGET);
|
// After optimistic apply, the fingerprint re-anchors to the replacement text.
|
||||||
|
assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, REPLACEMENT);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => {
|
test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => {
|
||||||
@@ -97,15 +99,18 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
|||||||
assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar");
|
assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reject leaves the document untouched and removes the proposal (PUC-3)", async () => {
|
test("reject reverts the optimistically-applied text and removes the proposal (PUC-3, F12-INV-48)", async () => {
|
||||||
const doc = await openDoc();
|
const doc = await openDoc();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const before = doc.getText();
|
const before = doc.getText();
|
||||||
const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2");
|
const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2");
|
||||||
await settle();
|
await settle();
|
||||||
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
|
// F12: optimistic apply has put "WOULD-BE replacement." in the buffer.
|
||||||
|
assert.ok(doc.getText().includes("WOULD-BE replacement."), "F12: optimistically applied");
|
||||||
|
// rejectByIdInPlace reverts the buffer to the original (PUC-3, INV-51).
|
||||||
|
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id), "rejectByIdInPlace removes + reverts");
|
||||||
await settle();
|
await settle();
|
||||||
assert.strictEqual(doc.getText(), before, "document untouched");
|
assert.strictEqual(doc.getText(), before, "document restored to pre-propose state");
|
||||||
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
|
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
|
||||||
assert.strictEqual(readSidecar().proposals.length, 0);
|
assert.strictEqual(readSidecar().proposals.length, 0);
|
||||||
});
|
});
|
||||||
@@ -114,25 +119,35 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
|||||||
let doc = await openDoc();
|
let doc = await openDoc();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const anchor = "A stable closing paragraph.";
|
const anchor = "A stable closing paragraph.";
|
||||||
await proposeViaCommand(doc, anchor, "A PROPOSED closing paragraph.", "turn-p3");
|
const appliedText = "A PROPOSED closing paragraph.";
|
||||||
|
await proposeViaCommand(doc, anchor, appliedText, "turn-p3");
|
||||||
await settle();
|
await settle();
|
||||||
|
// F12: optimistic apply has put the proposed text in the buffer (anchor → appliedText).
|
||||||
|
assert.ok(doc.getText().includes(appliedText), "F12: optimistically applied before reload");
|
||||||
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
||||||
|
// doc.getText() now has appliedText; the reload prepends a line.
|
||||||
doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText());
|
doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText());
|
||||||
await settle();
|
await settle();
|
||||||
api.proposalController.renderAll(doc);
|
api.proposalController.renderAll(doc);
|
||||||
const rendered = api.proposalController.getRendered(DOC_REL);
|
const rendered = api.proposalController.getRendered(DOC_REL);
|
||||||
assert.strictEqual(rendered.length, 1, "proposal survived reload");
|
assert.strictEqual(rendered.length, 1, "proposal survived reload");
|
||||||
assert.strictEqual(rendered[0].pending, true, "still decidable");
|
assert.strictEqual(rendered[0].pending, true, "still decidable");
|
||||||
const moved = doc.getText().indexOf(anchor);
|
// After F12 re-anchor, the fingerprint tracks the appliedText, not the original anchor.
|
||||||
assert.strictEqual(rendered[0].range.start, moved, "re-anchored after the move");
|
const moved = doc.getText().indexOf(appliedText);
|
||||||
|
assert.ok(moved >= 0, "applied text present in the reloaded document");
|
||||||
|
assert.strictEqual(rendered[0].range.start, moved, "re-anchored to appliedText after the move");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("editing the target text makes the proposal stale: flagged, accept refused, doc untouched (INV-11)", async () => {
|
test("editing the target text makes the proposal stale: flagged, accept refused, doc untouched (INV-11)", async () => {
|
||||||
let doc = await openDoc();
|
let doc = await openDoc();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const id = api.proposalController.getRendered(DOC_REL)[0].id;
|
const id = api.proposalController.getRendered(DOC_REL)[0].id;
|
||||||
const mangled = doc.getText().replace("A stable closing paragraph.", "A reworded closing paragraph.");
|
// After F12 optimistic apply the fingerprint tracks the APPLIED text ("A PROPOSED
|
||||||
doc = await externalWriteAndReload(vscode.Uri.file(path.join(WS, DOC_REL)), mangled);
|
// closing paragraph."), not the original. Mangle that text to make the proposal stale.
|
||||||
|
const preMangled = doc.getText();
|
||||||
|
const mangled = preMangled.replace("A PROPOSED closing paragraph.", "A reworded closing paragraph.");
|
||||||
|
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
||||||
|
doc = await externalWriteAndReload(uri, mangled);
|
||||||
await settle();
|
await settle();
|
||||||
api.proposalController.renderAll(doc);
|
api.proposalController.renderAll(doc);
|
||||||
assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale");
|
assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale");
|
||||||
@@ -143,22 +158,30 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
|||||||
assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)");
|
assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)");
|
||||||
// discard the husk so later tests see a clean sidecar
|
// discard the husk so later tests see a clean sidecar
|
||||||
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
|
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
|
||||||
|
// Restore the file so subsequent tests can find their fixture targets:
|
||||||
|
// replace the optimistically-applied text back to the original fixture text.
|
||||||
|
const restored = preMangled.replace("A PROPOSED closing paragraph.", "A stable closing paragraph.");
|
||||||
|
doc = await externalWriteAndReload(uri, restored);
|
||||||
|
await settle();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("multiple pending proposals coexist and are decidable out of order (PUC-5)", async () => {
|
test("multiple pending proposals coexist and are decidable out of order (PUC-5)", async () => {
|
||||||
const doc = await openDoc();
|
const doc = await openDoc();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4");
|
// Clean up any proposals left over from prior tests (F12: proposals persist in state).
|
||||||
const id2 = await proposeViaCommand(doc, "PROPOSED-BY-CLAUDE replacement sentence.", "A REPLACED-AGAIN sentence.", "turn-p5");
|
await api.proposalController.rejectAll(doc);
|
||||||
await settle();
|
await settle();
|
||||||
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2);
|
const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4");
|
||||||
|
const id2 = await proposeViaCommand(doc, "A stable closing paragraph.", "A REPLACED closing paragraph.", "turn-p5");
|
||||||
|
await settle();
|
||||||
|
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2, "two new proposals");
|
||||||
// decide the SECOND first, then the first — order independence
|
// decide the SECOND first, then the first — order independence
|
||||||
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true);
|
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true);
|
||||||
await settle();
|
await settle();
|
||||||
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true);
|
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true);
|
||||||
await settle();
|
await settle();
|
||||||
assert.ok(doc.getText().includes("An ACCEPTED opening paragraph."));
|
assert.ok(doc.getText().includes("An ACCEPTED opening paragraph."));
|
||||||
assert.ok(doc.getText().includes("A REPLACED-AGAIN sentence."));
|
assert.ok(doc.getText().includes("A REPLACED closing paragraph."));
|
||||||
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
|
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-1
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import assert from "node:assert";
|
||||||
|
import { describe, it, test, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
SCHEMA_VERSION,
|
SCHEMA_VERSION,
|
||||||
emptyArtifact,
|
emptyArtifact,
|
||||||
@@ -179,6 +180,19 @@ describe("unknown-field preservation (F5 SLICE-2, INV-15)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("serializeArtifact round-trips Proposal.original", () => {
|
||||||
|
const a = emptyArtifact("doc.md");
|
||||||
|
a.anchors["a_1"] = { fingerprint: { text: "new", before: "", after: "", lineHint: 0 } };
|
||||||
|
a.proposals.push({
|
||||||
|
id: "pr_1", anchorId: "a_1", replacement: "new",
|
||||||
|
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "s" } },
|
||||||
|
createdAt: "2026-06-26T00:00:00.000Z", original: "old", granularity: "block",
|
||||||
|
});
|
||||||
|
const json = serializeArtifact(a);
|
||||||
|
assert.match(json, /"original": "old"/);
|
||||||
|
assert.match(json, /"granularity": "block"/);
|
||||||
|
});
|
||||||
|
|
||||||
describe("attributions[] (F3 SLICE-1)", () => {
|
describe("attributions[] (F3 SLICE-1)", () => {
|
||||||
it("round-trips an attribution record through serialize → parse", () => {
|
it("round-trips an attribution record through serialize → parse", () => {
|
||||||
const a = emptyArtifact("docs/x.md");
|
const a = emptyArtifact("docs/x.md");
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type Proposal,
|
type Proposal,
|
||||||
} from "../src/model";
|
} from "../src/model";
|
||||||
import { CoauthorStore } from "../src/store";
|
import { CoauthorStore } from "../src/store";
|
||||||
import { addProposal, proposalBody, removeProposal } from "../src/proposalModel";
|
import { addProposal, proposalBody, removeProposal, setProposalApplied } from "../src/proposalModel";
|
||||||
|
|
||||||
const agent = {
|
const agent = {
|
||||||
kind: "agent" as const,
|
kind: "agent" as const,
|
||||||
@@ -104,4 +104,19 @@ describe("proposalModel helpers (spec §6.4)", () => {
|
|||||||
expect(body).toContain("- old line two");
|
expect(body).toContain("- old line two");
|
||||||
expect(body).toContain("+ new only line");
|
expect(body).toContain("+ new only line");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("setProposalApplied stores original and re-anchors the fingerprint to the applied text", () => {
|
||||||
|
const a = emptyArtifact("docs/x.md");
|
||||||
|
const { proposalId, anchorId } = addProposal(
|
||||||
|
a,
|
||||||
|
{ text: "old", before: "", after: "", lineHint: 0 },
|
||||||
|
"new",
|
||||||
|
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } },
|
||||||
|
{ granularity: "block" },
|
||||||
|
);
|
||||||
|
setProposalApplied(a, proposalId, { text: "new", before: "", after: "", lineHint: 0 }, "old");
|
||||||
|
const p = a.proposals.find((x) => x.id === proposalId)!;
|
||||||
|
expect(p.original).toBe("old");
|
||||||
|
expect(a.anchors[anchorId].fingerprint.text).toBe("new");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ describe("renderReview", () => {
|
|||||||
const html = renderReview("hello world", "hello", [], []);
|
const html = renderReview("hello world", "hello", [], []);
|
||||||
expect(html).toMatch(/<del>|cw-del/);
|
expect(html).toMatch(/<del>|cw-del/);
|
||||||
});
|
});
|
||||||
test("renderReview: a pending proposal renders a blue block with data-proposal-id and ✓/✗ actions", () => {
|
test("renderReview: a pending proposal renders a blue block with data-proposal-id and Accept/Reject actions", () => {
|
||||||
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
|
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
|
||||||
const html = renderReview("hello", "hello", [], proposals);
|
const html = renderReview("hello", "hello", [], proposals);
|
||||||
expect(html).toContain('class="cw-proposal"');
|
expect(html).toContain('class="cw-proposal"');
|
||||||
@@ -375,6 +375,55 @@ describe("renderReview", () => {
|
|||||||
const b = renderReview(doc, doc, [], proposals);
|
const b = renderReview(doc, doc, [], proposals);
|
||||||
expect(a).toBe(b);
|
expect(a).toBe(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renderReview renders an applied proposal ONCE, not also as a landed diff (INV-50)", () => {
|
||||||
|
const baseline = "# T\n\nThe brown fox.\n";
|
||||||
|
const current = "# T\n\nThe red fox.\n"; // proposal already optimistically applied
|
||||||
|
const proposals: ProposalView[] = [{
|
||||||
|
id: "pr_1",
|
||||||
|
anchorStart: current.indexOf("The red fox."),
|
||||||
|
anchorEnd: current.indexOf("The red fox.") + "The red fox.".length,
|
||||||
|
replaced: "The brown fox.", // original
|
||||||
|
replacement: "The red fox.",
|
||||||
|
}];
|
||||||
|
const html = renderReview(baseline, current, [], proposals);
|
||||||
|
// exactly one proposal block
|
||||||
|
expect((html.match(/cw-proposal/g) ?? []).length).toBe(1);
|
||||||
|
// the applied paragraph is NOT also emitted as a word-merged changed block
|
||||||
|
expect(html).not.toContain("<del>brown</del>"); // no double-render of the change as a baseline diff
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderReview keeps author coloring aligned on a block AFTER a length-changing pending proposal (INV-49/50)", () => {
|
||||||
|
// block 1 has a pending proposal whose applied text is MUCH LONGER than its
|
||||||
|
// original; block 2 carries a Claude authorship span (in currentText coords).
|
||||||
|
const baseline = "one short.\n\ntwo stable here.\n";
|
||||||
|
const current = "one MUCH LONGER REPLACED TEXT.\n\ntwo stable here.\n";
|
||||||
|
const proposals: ProposalView[] = [{
|
||||||
|
id: "pr_1",
|
||||||
|
anchorStart: current.indexOf("one MUCH LONGER REPLACED TEXT."),
|
||||||
|
anchorEnd: current.indexOf("one MUCH LONGER REPLACED TEXT.") + "one MUCH LONGER REPLACED TEXT.".length,
|
||||||
|
replaced: "one short.",
|
||||||
|
replacement: "one MUCH LONGER REPLACED TEXT.",
|
||||||
|
original: "one short.",
|
||||||
|
}];
|
||||||
|
const sStart = current.indexOf("stable");
|
||||||
|
const authorSpans = [{ start: sStart, end: sStart + "stable".length, author: "claude" as const }];
|
||||||
|
const html = renderReview(baseline, current, authorSpans, proposals);
|
||||||
|
// the Claude coloring wraps "stable" exactly — not shifted by the proposal's length delta.
|
||||||
|
expect(html).toMatch(/<span class="cw-by-claude">stable<\/span>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("proposalBlockHtml renders Accept/Reject controls with dropdown carets (#64)", () => {
|
||||||
|
const html = renderReview("a\n", "b\n", [], [
|
||||||
|
{ id: "pr_1", anchorStart: 0, anchorEnd: 1, replaced: "a", replacement: "b" },
|
||||||
|
]);
|
||||||
|
expect(html).toMatch(/data-action="accept"/);
|
||||||
|
expect(html).toMatch(/data-action="reject"/);
|
||||||
|
expect(html).toMatch(/data-action="acceptAll"/);
|
||||||
|
expect(html).toMatch(/data-action="rejectAll"/);
|
||||||
|
expect(html).toMatch(/Accept/);
|
||||||
|
expect(html).toMatch(/Reject/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
|
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
|
||||||
|
|||||||
Reference in New Issue
Block a user