Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b95d24269 | |||
| 980cf9eefb | |||
| ff09c3b915 | |||
| 7a1b30a671 | |||
| 3e7ad58247 | |||
| a90cc06dfa | |||
| 2961378ede | |||
| a0a94d76a9 | |||
| 7583165354 | |||
| 42740f7cc6 | |||
| 6c3e331fc3 | |||
| d8f2d7383a | |||
| 935fcc35ee | |||
| 93eeaf13b8 | |||
| 26b2212efd | |||
| 0967067cb3 | |||
| ef61b141b6 | |||
| 55fe663bd0 | |||
| 7b767a759d | |||
| 2a1d7623f5 | |||
| fdab92ae6d | |||
| c9050c3e08 | |||
| b3c476e4d9 | |||
| 15e6f02bdf | |||
| c3cecdfaa2 | |||
| 02fbe9c77e | |||
| ae19df78fb | |||
| 7b59c324d5 | |||
| dcdd2ffc1c | |||
| 1d284d2868 | |||
| 05ca497900 | |||
| c579f67a43 | |||
| 5d1000096a | |||
| 2955e8d929 | |||
| 44ef0a21db | |||
| 08557827da | |||
| 0ad040711f | |||
| 94b1a9b0c2 | |||
| 126d71fa06 | |||
| e671c4bf03 | |||
| 5d83f290f5 | |||
| e38fe1b8ca | |||
| 844a2789fd | |||
| 7b98249286 | |||
| d2ef9457c4 | |||
| 7e42d115c0 | |||
| 56cc9eb6ad | |||
| 2069029d73 | |||
| a99430c247 | |||
| 9432300e3c | |||
| a42e5f145d | |||
| 26628c0dbe | |||
| 644885c6ec |
@@ -8,3 +8,6 @@ test/e2e/fixtures/workspace/.threads/
|
||||
|
||||
# Sandbox playground churn (the EDH workspace - play freely, commit nothing)
|
||||
sandbox/.threads/
|
||||
|
||||
# brainstorming visual-companion scratch
|
||||
.superpowers/
|
||||
|
||||
@@ -14,9 +14,59 @@ catalog (a pure, key-free SDK call) in a notification and the
|
||||
Features shipped so far: F2 region-anchored threads (Feature #4), F3 live
|
||||
human/Claude attribution (Feature #6), F4 propose/accept diff flow
|
||||
(Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view
|
||||
toggle (Feature #17), F10 interactive review — **write left / review
|
||||
right** (Feature #29), and F11 — the **preview toolbar as the primary
|
||||
interaction surface** (Feature #43).
|
||||
data layer (Feature #17), and the **native-surfaces migration** (the
|
||||
"Coediting Markdown with a Machine" Solution Design) — the current UI, replacing
|
||||
the bespoke F7/F10/F11 review webview with VS Code's own chrome end to end. See
|
||||
**How it works** below for the current experience; the F7/F9/F10/F11 sections
|
||||
further down are kept as a **historical record** of the superseded webview (each
|
||||
now says so up top).
|
||||
|
||||
## How it works
|
||||
|
||||
There is **no bespoke panel**. Every surface below is native VS Code chrome —
|
||||
the editor title bar, status bar, Comments gutter, source control, diff editor,
|
||||
and the built-in Markdown preview:
|
||||
|
||||
1. **Enter coediting.** Run **"✦ Coedit this Document with Claude"** (command
|
||||
palette, editor title menu, or editor/explorer context menu) on a Markdown
|
||||
document — it captures a **baseline** (a clean checkpoint: git HEAD if the
|
||||
file is tracked and clean, otherwise a snapshot) to measure future changes
|
||||
against. Until a document is entered, none of the surfaces below attach to
|
||||
it. **"Stop editing with Claude"** reverses it.
|
||||
2. **Title bar + status bar are the everyday entry points.** Once coedited, the
|
||||
editor title bar carries **Review Changes**, **Ask Claude**, **Toggle
|
||||
Annotations**, and (in snapshot mode) **Mark Changes as Reviewed**; a
|
||||
`✦ Coediting · N changes` status-bar item shows the pending-change count and
|
||||
opens the review when clicked — no need to find the Source Control pane.
|
||||
3. **Ask Claude is comments-first.** **"Ask Claude"** (editor title, or select
|
||||
text → right-click → "Ask Claude to Edit") opens a focused comment box on
|
||||
the native **Comments API** — on your selection, or a top-anchored
|
||||
whole-document thread with none selected. Claude replies in-thread and
|
||||
offers an edit; accepting the offer turns it into one or more pending
|
||||
proposals.
|
||||
4. **Pending changes live in the buffer.** A proposal is **optimistically
|
||||
applied** straight into the editor (green/blue insertion tint, a struck
|
||||
deletion hint) with an inline **`✓ Keep` / `✗ Reject`** CodeLens above each
|
||||
changed block — and, once ≥2 proposals are pending, a top-of-file
|
||||
**`✓ Keep all (N)` / `✗ Reject all`** pair. Nothing is force-applied: a
|
||||
proposal whose target text changed underneath it is skipped, never guessed.
|
||||
5. **Native diff answers "what changed?"** **Review Changes** (editor title, or
|
||||
`Ctrl+Alt+R` / `Cmd+Alt+R`) opens VS Code's own diff editor — baseline on the
|
||||
left, your live document on the right — with the platform's inline/side-by-side
|
||||
toggle, navigation, and accessibility for free. A Source Control gutter/viewlet
|
||||
entry (quick-diff change bars, "Open Changes") is a bonus home for git users,
|
||||
never the required path.
|
||||
6. **The built-in Markdown preview is the annotated read.** "Open Preview to the
|
||||
Side" (or right-click a markdown file/tab → **"Open Cowriting Review
|
||||
Preview"**, `cowriting.openReviewPreview`) renders the document with
|
||||
authorship/change coloring **inside VS Code's own preview** — green = human,
|
||||
blue = Claude, strikethrough = deleted — toggled by **Toggle Annotations**
|
||||
(command or the title-bar eye icon). No separate webview, no separate
|
||||
persistence.
|
||||
|
||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-native-surfaces.md`
|
||||
(§5 UX Layout is the canonical source for this map). Migration plan:
|
||||
[`docs/superpowers/plans/2026-07-01-native-surfaces-migration.md`](docs/superpowers/plans/2026-07-01-native-surfaces-migration.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -55,6 +105,14 @@ Design: `vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md`. No li
|
||||
|
||||
## F3 — Live human/Claude attribution (Feature #6)
|
||||
|
||||
> **Commands superseded (native-surfaces migration).** The attribution **data
|
||||
> layer** below is current, but the Commands block is historical:
|
||||
> `Cowriting: Toggle Attribution` was retired — authorship/change coloring now
|
||||
> toggles via **Toggle Annotations** on a coedited document (How it works §6) —
|
||||
> and "Ask Claude to Edit Selection" is palette-hidden; use **Ask Claude to
|
||||
> Edit** (`Ctrl+Alt+E` / `Cmd+Alt+E`) or the comments-first **Ask Claude** (How
|
||||
> it works §3). Kept below as a historical record.
|
||||
|
||||
As you and Claude coauthor, every span in the buffer carries an author: human
|
||||
edits render with a subtle left border, Claude-authored spans with a background
|
||||
tint. Text that predates tracking stays plain — the honest record. Edits that
|
||||
@@ -134,7 +192,16 @@ record per the contract; git push/pull is the transport, no re-homing ever.
|
||||
|
||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`.
|
||||
|
||||
## F6 — Diff-view toggle (Feature #17, #19)
|
||||
## F6 — Diff-view toggle (Feature #17, #19) — SUPERSEDED
|
||||
|
||||
> **Superseded.** The two-pane `Ctrl+Alt+D` toggle this section describes was
|
||||
> deleted in #34; only the baseline **data layer** survived, now serving the
|
||||
> native diff editor (`Ctrl+Alt+R` / "Review Changes" — How it works §5) and the
|
||||
> built-in preview annotations (§6). The native-surfaces migration's Task 2
|
||||
> additionally **retired INV-18** (machine-landing auto-advance): a landed
|
||||
> Claude edit stays a visible change until "Mark Changes as Reviewed" (the
|
||||
> renamed "Pin Diff Baseline to Now"), it no longer auto-clears. Kept below as a
|
||||
> historical record.
|
||||
|
||||
**`Ctrl+Alt+D`** (the same chord on macOS — not `Cmd`; or **Cowriting: Toggle
|
||||
Diff View**) flips the focused document into a native `vscode.diff` against a
|
||||
@@ -166,7 +233,14 @@ instead of git archaeology.
|
||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-diff-view.md`.
|
||||
Live smoke: [`docs/MANUAL-SMOKE-F6.md`](docs/MANUAL-SMOKE-F6.md).
|
||||
|
||||
## F7 — Rendered track-changes preview (Feature #21)
|
||||
## F7 — Rendered track-changes preview (Feature #21) — SUPERSEDED
|
||||
|
||||
> **Superseded (Task 8, native-surfaces migration).** The bespoke webview this
|
||||
> section describes was deleted; its authorship/change coloring lives on
|
||||
> **inside VS Code's own built-in Markdown preview** — see **How it works** §6
|
||||
> above. Kept below as a historical record of the pre-migration design. (The
|
||||
> `Ctrl+Alt+R` chord below now opens the native **Review Changes** diff; the
|
||||
> annotated preview opens via **Open Cowriting Review Preview**, no chord.)
|
||||
|
||||
**`Ctrl+Alt+R`** (or **Cowriting: Open Track-Changes Preview**) opens a
|
||||
read-only webview **beside** a **Markdown** editor that renders the document and
|
||||
@@ -198,7 +272,12 @@ the manual smoke, not the sealed-sandbox E2E.
|
||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`.
|
||||
Live smoke: [`docs/MANUAL-SMOKE-F7.md`](docs/MANUAL-SMOKE-F7.md).
|
||||
|
||||
## F9 — Authorship view in the preview (Feature ~#27)
|
||||
## F9 — Authorship view in the preview (Feature ~#27) — SUPERSEDED
|
||||
|
||||
> **Superseded (Task 8, native-surfaces migration).** The F7 webview this mode
|
||||
> lived in was deleted; the built-in preview's annotations (How it works §6)
|
||||
> always show authorship + change coloring together — there is no separate
|
||||
> mode toggle. Kept below as a historical record.
|
||||
|
||||
The rendered preview (F7) gains a second mode, switched by a `[ Track changes |
|
||||
Authorship ]` toggle in its header. **Authorship** mode re-renders the current
|
||||
@@ -239,7 +318,14 @@ a plain PR revert with zero data migration.
|
||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-out-of-workspace.md`.
|
||||
Live smoke: [`docs/MANUAL-SMOKE-F8.md`](docs/MANUAL-SMOKE-F8.md).
|
||||
|
||||
## F10 — Interactive review: write left / review right (Feature #29)
|
||||
## F10 — Interactive review: write left / review right (Feature #29) — SUPERSEDED
|
||||
|
||||
> **Superseded (Task 8, native-surfaces migration).** The editor is no longer
|
||||
> "zero-annotation" — pending proposals now render optimistically applied
|
||||
> in-buffer with `✓ Keep`/`✗ Reject` CodeLens (F12), and review happens across
|
||||
> the native diff editor + built-in preview, not one dedicated webview. See
|
||||
> **How it works** above. Kept below as a historical record. (The `Ctrl+Alt+R`
|
||||
> chord below now opens the native **Review Changes** diff, not this panel.)
|
||||
|
||||
A clean, **zero-annotation editor** on the left; the rendered preview on the
|
||||
right as the **single interactive review surface**. The editor carries no
|
||||
@@ -266,7 +352,13 @@ they are no longer separate user surfaces.
|
||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-interactive-review.md`.
|
||||
Live smoke: [`docs/MANUAL-SMOKE-F10.md`](docs/MANUAL-SMOKE-F10.md).
|
||||
|
||||
## F11 — Preview toolbar as the primary interaction surface (Feature #43)
|
||||
## F11 — Preview toolbar as the primary interaction surface (Feature #43) — SUPERSEDED
|
||||
|
||||
> **Superseded (Task 8, native-surfaces migration).** The webview toolbar this
|
||||
> section describes was deleted; its two controls moved to native chrome —
|
||||
> **Pin baseline** is now **"Mark Changes as Reviewed"** in the editor title
|
||||
> bar, and **Ask Claude** is the comments-first ask (How it works §3). Kept
|
||||
> below as a historical record.
|
||||
|
||||
The review preview's **header toolbar** becomes the cockpit for the inner loop.
|
||||
Beside the existing **Annotations** switch it gains two controls:
|
||||
|
||||
@@ -0,0 +1,827 @@
|
||||
# Author-colored track changes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Render every change as a track-changes diff where **style = operation** (underline = inserted, strikethrough = removed) and **color = author** (human green/red, Claude blue/purple), in both the rendered preview pane and the main editor pane.
|
||||
|
||||
**Architecture:** Fuse the two color systems that exist today — the *semantic* diff (green=add/red=remove) and the *authorship* tint (cw-by-claude/cw-by-human) — into one author-colored diff. Insertions are colored by their attribution span (data already exists). Deletions are colored by an **adjacency heuristic**: a struck run inherits the author of the insertion it is paired with in the same changed hunk; a standalone removed block falls back to neutral. Phase 1 does this in the pure render engine (preview). Phase 2 reverses INV-32 to bring the same author-colored track changes inline into the editor.
|
||||
|
||||
**Tech Stack:** TypeScript, VS Code extension API, `markdown-it`, `diff` (jsdiff), vitest (unit), Playwright/electron host harness (E2E). Render engine (`src/trackChangesModel.ts`) is pure and vscode-free.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Render engine stays pure / vscode-free / deterministic** — `src/trackChangesModel.ts` must not import `vscode`; same inputs → identical HTML (INV-22).
|
||||
- **Author color mapping (verbatim):** human added `#3fb950` / human removed `#f85149` / Claude added `#58a6ff` / Claude removed `#bc8cff`. Insert = 2px underline in the author color + faint author-hued background; remove = line-through in the author color + faint background.
|
||||
- **Convention:** underline = inserted, strikethrough = removed, color = author.
|
||||
- **"Color means changed since the pinned baseline."** Unchanged-since-baseline text renders plain (no author coloring). The pin→clean behavior (#48) is preserved: a pinned, zero-diff doc renders with no annotation.
|
||||
- **Deletion authorship = adjacency heuristic** (locked decision): paired deletions inherit the paired insertion's author; standalone deletions render neutral.
|
||||
- **Two roles only:** `human` and `claude` (`AuthorKind`). No arbitrary palettes, no user-configurable colors (YAGNI).
|
||||
- **Test command:** `npm test` (vitest, unit). E2E: `npm run test:e2e`. Typecheck: `npm run typecheck`.
|
||||
- Commit after each task. Branch off `main` (do not commit to `main` directly).
|
||||
|
||||
---
|
||||
|
||||
# Phase 1 — Preview pane (independently shippable)
|
||||
|
||||
Delivers the full author-colored track-changes language in the rendered preview, where the attribution data already exists.
|
||||
|
||||
## File Structure (Phase 1)
|
||||
|
||||
- `src/trackChangesModel.ts` — add `authorAt()` + `wordDiffByAuthor()` pure helpers; route the review path through them; color proposal blocks by author; stop coloring unchanged blocks. Add `author?` to `ProposalView`.
|
||||
- `src/trackChangesPreview.ts` — pass each proposal's author into its `ProposalView`.
|
||||
- `media/preview.css` — author×operation classes; retire the semantic green/red and flat authorship tints.
|
||||
- `test/trackChangesModel.test.ts` — new unit tests; update tests asserting old semantic classes.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `authorAt()` + `wordDiffByAuthor()` pure helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/trackChangesModel.ts` (add after `wordMergedMarkdown`, ~line 475)
|
||||
- Test: `test/trackChangesModel.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `AuthorKind`, `AuthorSpan` (trackChangesModel.ts:557-562), `diffWordsWithSpace` (already imported, line 12).
|
||||
- Produces:
|
||||
- `authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null` — the author whose span covers `offset` (half-open), else null.
|
||||
- `wordDiffByAuthor(beforeRaw: string, afterRaw: string, afterStart: number, spans: AuthorSpan[]): string` — inline HTML where each inserted run is `<ins class="cw-ins-{author}">` (author looked up at the run's `afterStart`-relative landed offset) and each removed run is `<del class="cw-del-{author}">` (author = the insertion it's paired with in the same change cluster; `cw-del-none` if standalone). Unchanged parts are emitted verbatim.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `test/trackChangesModel.test.ts` (import `authorAt`, `wordDiffByAuthor` from `../src/trackChangesModel`):
|
||||
|
||||
```ts
|
||||
describe("authorAt", () => {
|
||||
const spans: AuthorSpan[] = [
|
||||
{ start: 0, end: 5, author: "human" },
|
||||
{ start: 5, end: 10, author: "claude" },
|
||||
];
|
||||
test("returns the covering span's author (half-open)", () => {
|
||||
expect(authorAt(0, spans)).toBe("human");
|
||||
expect(authorAt(4, spans)).toBe("human");
|
||||
expect(authorAt(5, spans)).toBe("claude");
|
||||
expect(authorAt(9, spans)).toBe("claude");
|
||||
});
|
||||
test("returns null outside any span", () => {
|
||||
expect(authorAt(10, spans)).toBeNull();
|
||||
expect(authorAt(-1, spans)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wordDiffByAuthor", () => {
|
||||
test("an inserted run is colored by the author covering its landed offset", () => {
|
||||
// before "hello " ; after "hello brave " ; "brave " inserted at landed offset 6..12
|
||||
const spans: AuthorSpan[] = [{ start: 6, end: 12, author: "claude" }];
|
||||
const html = wordDiffByAuthor("hello ", "hello brave ", 0, spans);
|
||||
expect(html).toContain('<ins class="cw-ins-claude">brave </ins>');
|
||||
expect(html).toContain("hello ");
|
||||
});
|
||||
test("a paired deletion inherits the paired insertion's author (adjacency)", () => {
|
||||
// "light" -> "dark", both in one cluster; "dark" is claude-inserted
|
||||
const spans: AuthorSpan[] = [{ start: 0, end: 4, author: "claude" }];
|
||||
const html = wordDiffByAuthor("light", "dark", 0, spans);
|
||||
expect(html).toContain('<del class="cw-del-claude">light</del>');
|
||||
expect(html).toContain('<ins class="cw-ins-claude">dark</ins>');
|
||||
});
|
||||
test("a standalone deletion (no paired insertion) is neutral", () => {
|
||||
const html = wordDiffByAuthor("keep this word", "keep word", 0, []);
|
||||
expect(html).toContain('<del class="cw-del-none">this </del>');
|
||||
expect(html).not.toContain("cw-del-human");
|
||||
expect(html).not.toContain("cw-del-claude");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm test -- trackChangesModel`
|
||||
Expected: FAIL — `authorAt`/`wordDiffByAuthor` not exported.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Add to `src/trackChangesModel.ts` after `wordMergedMarkdown` (line 475):
|
||||
|
||||
```ts
|
||||
/** The author whose span covers `offset` (half-open [start,end)), or null. */
|
||||
export function authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null {
|
||||
for (const s of spans) if (offset >= s.start && offset < s.end) return s.author;
|
||||
return null;
|
||||
}
|
||||
|
||||
const insClass = (a: AuthorKind | null): string => `cw-ins-${a ?? "none"}`;
|
||||
const delClass = (a: AuthorKind | null): string => `cw-del-${a ?? "none"}`;
|
||||
|
||||
/**
|
||||
* Author-colored word diff for a changed PROSE block in the review.
|
||||
* `afterStart` is the landed-text offset of `afterRaw[0]` so an inserted run's
|
||||
* offset maps into `spans`. Insertions are colored by the author covering their
|
||||
* offset; deletions inherit the author of the insertion they are paired with in
|
||||
* the same contiguous change cluster (adjacency heuristic) — `cw-del-none` when a
|
||||
* cluster has no insertion. Pure, deterministic.
|
||||
*/
|
||||
export function wordDiffByAuthor(
|
||||
beforeRaw: string,
|
||||
afterRaw: string,
|
||||
afterStart: number,
|
||||
spans: AuthorSpan[],
|
||||
): string {
|
||||
const parts = diffWordsWithSpace(beforeRaw, afterRaw);
|
||||
// First pass: find each change cluster's insertion author (first added run).
|
||||
// A cluster is a maximal run of added/removed parts bounded by unchanged parts.
|
||||
let afterOff = afterStart;
|
||||
const clusterAuthor: (AuthorKind | null)[] = []; // per part index, the cluster's del author
|
||||
{
|
||||
let off = afterStart;
|
||||
let i = 0;
|
||||
while (i < parts.length) {
|
||||
const p = parts[i];
|
||||
if (!p.added && !p.removed) {
|
||||
clusterAuthor[i] = null;
|
||||
off += p.value.length;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// a cluster: scan to its end, capturing the first added run's author
|
||||
let j = i;
|
||||
let author: AuthorKind | null = null;
|
||||
let scanOff = off;
|
||||
while (j < parts.length && (parts[j].added || parts[j].removed)) {
|
||||
if (parts[j].added && author === null) author = authorAt(scanOff, spans);
|
||||
if (parts[j].added) scanOff += parts[j].value.length;
|
||||
j++;
|
||||
}
|
||||
for (let k = i; k < j; k++) clusterAuthor[k] = author;
|
||||
off = scanOff;
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
// Second pass: emit.
|
||||
const out: string[] = [];
|
||||
parts.forEach((p, i) => {
|
||||
if (p.added) {
|
||||
const a = authorAt(afterOff, spans);
|
||||
out.push(`<ins class="${insClass(a)}">${p.value}</ins>`);
|
||||
afterOff += p.value.length;
|
||||
} else if (p.removed) {
|
||||
out.push(`<del class="${delClass(clusterAuthor[i] ?? null)}">${p.value}</del>`);
|
||||
} else {
|
||||
out.push(p.value);
|
||||
afterOff += p.value.length;
|
||||
}
|
||||
});
|
||||
return out.join("");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test -- trackChangesModel`
|
||||
Expected: PASS (new describes green).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
|
||||
git commit -m "feat: authorAt + wordDiffByAuthor pure helpers (author-colored word diff w/ adjacency del)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Route the review path through `wordDiffByAuthor`; stop coloring unchanged blocks
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/trackChangesModel.ts` — `renderReviewOp` (769-780), the `colored` closure + changed-op handling in `renderReview` (889-897)
|
||||
- Test: `test/trackChangesModel.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `wordDiffByAuthor` (Task 1), `landedSpans` + `ranges` already computed in `renderReview`.
|
||||
- Produces: review HTML where changed prose blocks emit `cw-ins-{author}`/`cw-del-{author}`, added blocks are author-colored as insertions, and **unchanged blocks render plain**.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to the `renderReview` describe in `test/trackChangesModel.test.ts`:
|
||||
|
||||
```ts
|
||||
test("renderReview: a changed prose block colors ins/del by author (claude replace)", () => {
|
||||
// baseline "light" -> current "dark"; "dark" attributed to claude at 0..4
|
||||
const html = renderReview("light", "dark", [{ start: 0, end: 4, author: "claude" }], []);
|
||||
expect(html).toContain('cw-ins-claude');
|
||||
expect(html).toContain('cw-del-claude'); // adjacency: struck "light" inherits claude
|
||||
});
|
||||
|
||||
test("renderReview: an unchanged block renders plain (no author coloring)", () => {
|
||||
const doc = "Alpha stays.";
|
||||
const html = renderReview(doc, doc, [{ start: 0, end: 12, author: "human" }], []);
|
||||
expect(html).not.toContain("cw-by-");
|
||||
expect(html).not.toContain("cw-ins-");
|
||||
});
|
||||
|
||||
test("renderReview: an added block is author-colored as an insertion", () => {
|
||||
// baseline empty-ish -> add a new paragraph authored by human
|
||||
const html = renderReview("Keep.", "Keep.\n\nFresh line.", [{ start: 6, end: 17, author: "human" }], []);
|
||||
expect(html).toContain("cw-ins-human");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm test -- trackChangesModel`
|
||||
Expected: FAIL — changed blocks still emit plain `<ins>/<del>` via `wordMergedMarkdown`; unchanged blocks still carry `cw-by-*`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
In `src/trackChangesModel.ts`:
|
||||
|
||||
**(a)** Change `renderReviewOp` (769-780) so a changed PROSE block uses author coloring. Give it the colored-insertion path for `added` and the author word-diff for non-atomic `changed`. Replace the function body:
|
||||
|
||||
```ts
|
||||
function renderReviewOp(
|
||||
op: BlockOp,
|
||||
render: (src: string) => string,
|
||||
colored: (raw: string) => string,
|
||||
src: string,
|
||||
changedHtml?: string,
|
||||
): string {
|
||||
// A non-atomic changed prose block is pre-rendered with author-colored ins/del
|
||||
// (changedHtml). Removed blocks and atomic changed fences stay neutral via renderOp
|
||||
// (standalone deletion → neutral, per the adjacency heuristic). `src` is "" for a
|
||||
// removed block (no live source — INV-36).
|
||||
if (op.kind === "changed" && !op.atomic && changedHtml !== undefined) {
|
||||
return `<div class="cw-blk cw-changed"${src}>${changedHtml}</div>`;
|
||||
}
|
||||
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
|
||||
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${colored(op.block.raw)}</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
**(b)** In `renderReview` (the loop at 889-897), compute the author-colored changed HTML and stop coloring unchanged blocks. Replace the loop body:
|
||||
|
||||
```ts
|
||||
let ci = 0; // pointer into ranges; advances for every op with a current-side block
|
||||
const bodyParts: string[] = [];
|
||||
for (const op of ops) {
|
||||
const blockIndex = op.kind === "removed" ? -1 : ci;
|
||||
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
||||
// Only ADDED blocks are author-colored (they are insertions since baseline);
|
||||
// UNCHANGED blocks render plain (color means "changed since baseline").
|
||||
const colored = (raw: string): string =>
|
||||
blk && !clean && op.kind === "added" ? colorByAuthor(raw, blk.start, landedSpans, render) : render(raw);
|
||||
// A non-atomic changed prose block: author-colored word diff (ins by author,
|
||||
// del by adjacency). `blk.start` is the landed offset of the after-side text.
|
||||
const changedHtml =
|
||||
op.kind === "changed" && !op.atomic && blk && !clean
|
||||
? render(wordDiffByAuthor(op.before.raw, op.block.raw, blk.start, landedSpans))
|
||||
: undefined;
|
||||
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk), changedHtml));
|
||||
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
||||
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
||||
}
|
||||
for (const p of trailing) bodyParts.push(proposalBlockHtml(p, render));
|
||||
return bodyParts.join("\n");
|
||||
```
|
||||
|
||||
> Note: `render(wordDiffByAuthor(...))` passes the HTML-bearing string through markdown-it (same as `wordMergedMarkdown` was rendered). `colorByAuthor` on added blocks emits `cw-by-{author}` spans — Task 4 restyles those to insert styling in CSS.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test -- trackChangesModel`
|
||||
Expected: PASS for the new tests.
|
||||
|
||||
- [ ] **Step 5: Update tests that assert the OLD review markup**
|
||||
|
||||
Two existing tests assert the old semantic output (test/trackChangesModel.test.ts:226-232). Update them:
|
||||
|
||||
```ts
|
||||
test("renderReview: human addition since baseline renders author-colored ins", () => {
|
||||
const html = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
|
||||
expect(html).toContain("cw-ins-human");
|
||||
});
|
||||
test("renderReview: a standalone deletion since baseline renders neutral struck del", () => {
|
||||
const html = renderReview("hello world", "hello", [], []);
|
||||
expect(html).toMatch(/cw-del-none|cw-removed/);
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- trackChangesModel`
|
||||
Expected: PASS (whole file green).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
|
||||
git commit -m "feat: author-colored changed/added blocks in review; unchanged blocks render plain"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Color pending proposal blocks by author
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/trackChangesModel.ts` — `ProposalView` (731-742) + `proposalBlockHtml` (744-767)
|
||||
- Modify: `src/trackChangesPreview.ts` — populate `author` on each `ProposalView`
|
||||
- Test: `test/trackChangesModel.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AuthorKind` (557).
|
||||
- Produces: `ProposalView.author?: AuthorKind` (defaults to `"claude"`); `proposalBlockHtml` emits `cw-del-{author}` / `cw-ins-{author}`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```ts
|
||||
test("renderReview: a proposal block colors its del/ins by author (claude default)", () => {
|
||||
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
|
||||
const html = renderReview("hello", "hello", [], proposals);
|
||||
expect(html).toContain('cw-del-claude');
|
||||
expect(html).toContain('cw-ins-claude');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm test -- trackChangesModel`
|
||||
Expected: FAIL — `proposalBlockHtml` still emits `cw-del` / `cw-add`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
In `src/trackChangesModel.ts`, add `author?` to `ProposalView` (after line 741):
|
||||
|
||||
```ts
|
||||
/** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */
|
||||
original?: string;
|
||||
/** Who made the proposal — colors the del/ins. Defaults to "claude". */
|
||||
author?: AuthorKind;
|
||||
}
|
||||
```
|
||||
|
||||
Update `proposalBlockHtml` (753-754):
|
||||
|
||||
```ts
|
||||
const who = p.author ?? "claude";
|
||||
const before = p.replaced ? `<del class="cw-del-${who}">${safe(p.replaced)}</del>` : "";
|
||||
const after = `<ins class="cw-ins-${who}">${safe(p.replacement)}</ins>`;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test -- trackChangesModel`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Populate `author` from the live proposal**
|
||||
|
||||
In `src/trackChangesPreview.ts` find where `ProposalView`s are built for `renderReview` (the `listProposals` mapping near line 373). Add `author` from the proposal's provenance. Locate the mapping and add:
|
||||
|
||||
```ts
|
||||
author: p.author?.kind === "agent" ? "claude" : "human",
|
||||
```
|
||||
|
||||
(where `p` is the live `Proposal` with `author: Provenance`). If the preview builds `ProposalView` via a helper, add the field there. Run `npm run typecheck` to confirm the field name matches the live `Proposal.author` shape (`model.ts:79-108`).
|
||||
|
||||
- [ ] **Step 6: Run typecheck + tests**
|
||||
|
||||
Run: `npm run typecheck && npm test -- trackChangesModel`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/trackChangesModel.ts src/trackChangesPreview.ts test/trackChangesModel.test.ts
|
||||
git commit -m "feat: color pending proposal blocks by author (Claude blue/purple)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: CSS — author×operation color system (preview)
|
||||
|
||||
**Files:**
|
||||
- Modify: `media/preview.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: classes emitted by Tasks 1-3: `cw-ins-human|claude|none`, `cw-del-human|claude|none`, `cw-by-human|claude` (added blocks), `cw-added`, `cw-removed`, `cw-changed`, `.cw-proposal`.
|
||||
- Produces: the locked color mapping; unchanged text plain.
|
||||
|
||||
- [ ] **Step 1: Replace the semantic diff + authorship-tint rules**
|
||||
|
||||
In `media/preview.css`, replace lines 21-33 and 55-58 with author-colored rules. New block:
|
||||
|
||||
```css
|
||||
/* Author-colored track changes — style = operation, color = author.
|
||||
underline = inserted, strikethrough = removed; human green/red, Claude blue/purple. */
|
||||
#cw-summary .cw-add { color: #3fb950; }
|
||||
#cw-summary .cw-del { color: #f85149; }
|
||||
.cw-blk { position: relative; }
|
||||
|
||||
/* base ins/del carry the operation; author classes carry the color */
|
||||
ins { text-decoration: none; }
|
||||
del { text-decoration: line-through; opacity: 0.7; }
|
||||
|
||||
.cw-ins-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
|
||||
.cw-ins-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
|
||||
.cw-ins-none { background: var(--vscode-diffEditor-insertedTextBackground, rgba(63,185,80,0.14)); text-decoration: none; }
|
||||
.cw-del-human { background: rgba(248,81,73,0.11); text-decoration: line-through; text-decoration-color: #f85149; }
|
||||
.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; }
|
||||
.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; }
|
||||
|
||||
/* whole-block ops: an added block is an insertion (author-colored inside via cw-by-*);
|
||||
a standalone removed block is neutral struck (adjacency heuristic fallback) */
|
||||
.cw-added { background: transparent; }
|
||||
.cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.10)); text-decoration: line-through; opacity: 0.65; }
|
||||
.cw-changed { outline: none; }
|
||||
|
||||
/* added-block author runs (colorByAuthor sentinels) read as insertions */
|
||||
.cw-by-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
|
||||
.cw-by-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
|
||||
.cw-blk.cw-by-claude, .cw-blk.cw-by-human, .cw-blk.cw-mixed { outline: 2px solid currentColor; outline-offset: 2px; background: transparent; }
|
||||
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
|
||||
#cw-summary .cw-prop { opacity: 0.85; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Recolor the proposal block accent to neutral (author lives inside it now)**
|
||||
|
||||
The proposal block's left border is currently blue (`--vscode-charts-blue`). Keep it as a neutral "pending" frame — the del/ins inside now carry the author color. Change `media/preview.css:79-80`:
|
||||
|
||||
```css
|
||||
border-left: 3px solid var(--vscode-panel-border, #555);
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Manual visual verification**
|
||||
|
||||
Run the extension host (or load the smoke doc) and confirm in the preview: human insertions green-underlined, Claude insertions blue-underlined, Claude deletions purple-struck, standalone deletions neutral, unchanged text plain.
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: builds clean (CSS is bundled into the webview assets per the build).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add media/preview.css
|
||||
git commit -m "feat: author-colored track-changes CSS for the preview (human green/red, Claude blue/purple)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Phase-1 host E2E — author colors render in the preview
|
||||
|
||||
**Files:**
|
||||
- Test: the existing F10/F7 preview E2E suite (find under `test/e2e/` — the preview/review specs)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the rendered preview webview HTML.
|
||||
|
||||
- [ ] **Step 1: Write the failing E2E assertions**
|
||||
|
||||
In the preview E2E spec, after producing a Claude change + a human change on a doc, assert the webview body contains `cw-ins-claude` and `cw-ins-human`, and that an unchanged paragraph has no `cw-ins-`/`cw-by-` class. (Follow the existing pattern in that spec for opening the preview and reading `panel.webview` HTML or the DOM via the harness.)
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails (or update an assertion that checked old classes)**
|
||||
|
||||
Run: `npm run test:e2e`
|
||||
Expected: FAIL if any existing assertion checked `cw-add`/`cw-by-*`-as-authorship; update those to the new classes.
|
||||
|
||||
- [ ] **Step 3: Make it pass**
|
||||
|
||||
Adjust any E2E assertion referencing retired classes (`cw-add`, semantic `ins`/`del` colors, `cw-by-*` on unchanged blocks) to the new author classes.
|
||||
|
||||
Run: `npm run test:e2e`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add test/e2e
|
||||
git commit -m "test(e2e): assert author-colored track changes render in the preview"
|
||||
```
|
||||
|
||||
**>>> Phase 1 is independently shippable here. Open a PR for the preview pane before starting Phase 2, or continue.**
|
||||
|
||||
---
|
||||
|
||||
# Phase 2 — Editor pane (reverses INV-32)
|
||||
|
||||
Brings the same author-colored track changes inline into the main editor: all changes-since-baseline (committed + pending), not just pending proposals.
|
||||
|
||||
## File Structure (Phase 2)
|
||||
|
||||
- `src/editorProposalController.ts` — per-author decoration types; recolor proposals; subscribe to baseline + attribution; decorate all committed insertions (author-colored) + committed deletion hints (adjacency); overlap stacks naturally.
|
||||
- `src/extension.ts` — inject `DiffViewController` + `AttributionController` into `EditorProposalController`.
|
||||
- Test: editor E2E suite.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Per-author decoration types; recolor proposals to Claude
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/editorProposalController.ts` (20-27 decoration types; 80-106 renderEditor)
|
||||
- Test: editor E2E
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `decorationPlan` (already imported), proposal list.
|
||||
- Produces: four insertion decoration types (`human`/`claude`) keyed by author, deletion-hint types colored by author; proposals decorate with Claude colors.
|
||||
|
||||
- [ ] **Step 1: Replace the two decoration types with per-author maps**
|
||||
|
||||
In `src/editorProposalController.ts`, replace lines 20-27:
|
||||
|
||||
```ts
|
||||
private readonly insDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = {
|
||||
human: vscode.window.createTextEditorDecorationType({
|
||||
backgroundColor: "rgba(63,185,80,0.14)", borderColor: "#3fb950",
|
||||
border: "0 0 2px 0", borderStyle: "solid",
|
||||
}),
|
||||
claude: vscode.window.createTextEditorDecorationType({
|
||||
backgroundColor: "rgba(88,166,255,0.15)", borderColor: "#58a6ff",
|
||||
border: "0 0 2px 0", borderStyle: "solid",
|
||||
}),
|
||||
};
|
||||
private readonly delDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = {
|
||||
human: vscode.window.createTextEditorDecorationType({ after: { color: "#f85149" } }),
|
||||
claude: vscode.window.createTextEditorDecorationType({ after: { color: "#bc8cff" } }),
|
||||
};
|
||||
```
|
||||
|
||||
Update the constructor's disposables push (39-40) to dispose all four:
|
||||
|
||||
```ts
|
||||
this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.lensEmitter,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Recolor proposal decorations to Claude in `renderEditor`**
|
||||
|
||||
Replace the body of `renderEditor` (81-106) so it groups ranges per author and applies the right decoration type. Proposals are Claude:
|
||||
|
||||
```ts
|
||||
private renderEditor(editor: vscode.TextEditor): void {
|
||||
const doc = editor.document;
|
||||
const clearAll = () => {
|
||||
for (const a of ["human", "claude"] as const) {
|
||||
editor.setDecorations(this.insDeco[a], []);
|
||||
editor.setDecorations(this.delDeco[a], []);
|
||||
}
|
||||
};
|
||||
if (doc.languageId !== "markdown") return clearAll();
|
||||
const key = this.proposals.keyFor(doc);
|
||||
const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] };
|
||||
const del: Record<"human" | "claude", vscode.DecorationOptions[]> = { human: [], claude: [] };
|
||||
// Pending proposals — always Claude (INV: proposals are agent-authored).
|
||||
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 i of plan.insertions) ins.claude.push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
|
||||
for (const d of plan.deletions) {
|
||||
del.claude.push({
|
||||
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
|
||||
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
|
||||
});
|
||||
}
|
||||
}
|
||||
// Committed changes-since-baseline are added in Task 7 (pushes into ins/del[author]).
|
||||
this.decorateCommitted(editor, ins, del); // no-op stub until Task 7
|
||||
for (const a of ["human", "claude"] as const) {
|
||||
editor.setDecorations(this.insDeco[a], ins[a]);
|
||||
editor.setDecorations(this.delDeco[a], del[a]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Overridden in Task 7 to add committed (non-proposal) author-colored changes. */
|
||||
private decorateCommitted(
|
||||
_editor: vscode.TextEditor,
|
||||
_ins: Record<"human" | "claude", vscode.Range[]>,
|
||||
_del: Record<"human" | "claude", vscode.DecorationOptions[]>,
|
||||
): void {}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Typecheck + build**
|
||||
|
||||
Run: `npm run typecheck && npm run build`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 4: E2E — proposal shows Claude colors**
|
||||
|
||||
Update/extend the editor E2E to assert a pending proposal decorates (smoke: the decoration types exist and apply; the harness asserts via the proposal-applied path used today). Run: `npm run test:e2e` → PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/editorProposalController.ts test/e2e
|
||||
git commit -m "feat: per-author editor decoration types; proposals decorate in Claude blue/purple"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Decorate committed changes-since-baseline by author (reverse INV-32)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/editorProposalController.ts` — inject baseline + attribution; implement `decorateCommitted`; subscribe to baseline/attribution change events
|
||||
- Modify: `src/extension.ts` (line 122) — pass the two new deps
|
||||
- Test: editor E2E
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DiffViewController.getBaseline(uri)` (diffViewController.ts:133) + `onDidChangeBaseline` (32); `AttributionController.spansFor(document)` (attributionController.ts:405); pure `wordEditHunks` + `authorAt` (trackChangesModel.ts).
|
||||
- Produces: committed insertions colored by `authorAt(currentOffset)`; committed deletion hints colored by adjacency (the paired insertion's author).
|
||||
|
||||
- [ ] **Step 1: Inject the new dependencies**
|
||||
|
||||
Change the constructor signature (38):
|
||||
|
||||
```ts
|
||||
constructor(
|
||||
private readonly proposals: ProposalController,
|
||||
private readonly diffView: import("./diffViewController").DiffViewController,
|
||||
private readonly attribution: import("./attributionController").AttributionController,
|
||||
) {
|
||||
```
|
||||
|
||||
Add subscriptions in the constructor disposables list:
|
||||
|
||||
```ts
|
||||
this.diffView.onDidChangeBaseline(({ uri }) => {
|
||||
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
|
||||
if (ed) this.renderEditor(ed);
|
||||
}),
|
||||
```
|
||||
|
||||
In `src/extension.ts` (122) pass the deps (both already constructed above — `diffViewController` and `attributionController`):
|
||||
|
||||
```ts
|
||||
const editorProposalController = new EditorProposalController(
|
||||
proposalController,
|
||||
diffViewController,
|
||||
attributionController,
|
||||
);
|
||||
```
|
||||
|
||||
(Confirm the local variable names in `extension.ts` for the diff-view + attribution controllers via `npm run typecheck`.)
|
||||
|
||||
- [ ] **Step 2: Implement `decorateCommitted`**
|
||||
|
||||
Replace the stub with the real implementation. It diffs baseline → current, attributes insertions, and colors deletion hints by adjacency:
|
||||
|
||||
```ts
|
||||
private decorateCommitted(
|
||||
editor: vscode.TextEditor,
|
||||
ins: Record<"human" | "claude", vscode.Range[]>,
|
||||
del: Record<"human" | "claude", vscode.DecorationOptions[]>,
|
||||
): void {
|
||||
const doc = editor.document;
|
||||
const baseline = this.diffView.getBaseline(doc.uri.toString());
|
||||
if (!baseline || baseline.reason === "pinned") return; // pin→clean: no committed marks
|
||||
const current = doc.getText();
|
||||
const spans = this.attribution.spansFor(doc); // AuthorSpan[] in current coords
|
||||
const hunks = wordEditHunks(baseline.text, current); // start/end index INTO current
|
||||
for (const h of hunks) {
|
||||
// The replacement text occupies [h.start, h.start+replacement.length) in current.
|
||||
// Re-derive the intra-hunk word diff to split ins vs del and find the author.
|
||||
const original = baseline.text.slice(/* mapped */ 0, 0); // see note
|
||||
const plan = decorationPlan(h.start, hunkOriginal(baseline.text, current, h), current.slice(h.start, h.end));
|
||||
const author = authorAt(h.start, spans) ?? "human";
|
||||
for (const i of plan.insertions) ins[author].push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
|
||||
for (const d of plan.deletions) {
|
||||
del[author].push({
|
||||
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
|
||||
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Implementation note for the engineer:** `wordEditHunks(baseline, current)` returns hunks whose `start`/`end` index into **current** with a `replacement` that is the *baseline* text for that span (see trackChangesModel.ts:241-264 — `replacement` accumulates `removed` parts = baseline text, `end` advances over them). So for a committed change, the *original* (baseline) text of a hunk is `h.replacement` and the *applied* (current) text is `current.slice(h.start, h.end)`. Call `decorationPlan(h.start, h.replacement, current.slice(h.start, h.end))` directly — drop the `hunkOriginal`/`original` placeholder lines above. Verify this against a unit test before wiring (Step 3).
|
||||
|
||||
- [ ] **Step 3: Unit-test the hunk→plan derivation (pure)**
|
||||
|
||||
Because the offset semantics are subtle, add a pure unit test in `test/trackChangesModel.test.ts` proving `wordEditHunks` + `decorationPlan` reconstruct a committed change. Add an exported helper if needed:
|
||||
|
||||
```ts
|
||||
test("committed change: wordEditHunks replacement is baseline text; current slice is applied", () => {
|
||||
const baseline = "the light mode";
|
||||
const current = "the dark mode";
|
||||
const [h] = wordEditHunks(baseline, current);
|
||||
expect(h.replacement).toContain("light"); // baseline side
|
||||
expect(current.slice(h.start, h.end)).toContain("dark"); // applied side
|
||||
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
||||
expect(plan.insertions.length).toBeGreaterThan(0);
|
||||
expect(plan.deletions.some((d) => d.text.includes("light"))).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npm test -- trackChangesModel` → PASS. Then finalize `decorateCommitted` to use `h.replacement` + `current.slice(h.start, h.end)` (removing the placeholder lines).
|
||||
|
||||
- [ ] **Step 4: Add the attribution-change refresh**
|
||||
|
||||
Editor decorations must refresh when attribution changes (e.g. after a human edit). If `AttributionController` exposes a change event, subscribe to it and call `renderEditor`; otherwise refresh on `vscode.workspace.onDidChangeTextDocument` for the active editor (debounced like `scheduleApply`). Add to the constructor disposables:
|
||||
|
||||
```ts
|
||||
vscode.workspace.onDidChangeTextDocument((e) => {
|
||||
const ed = vscode.window.activeTextEditor;
|
||||
if (ed && e.document === ed.document) this.scheduleRender(ed);
|
||||
}),
|
||||
```
|
||||
|
||||
with a small `scheduleRender(ed)` that debounces `renderEditor(ed)` (mirror `scheduleApply`, 50ms).
|
||||
|
||||
- [ ] **Step 5: Typecheck + build + E2E**
|
||||
|
||||
Run: `npm run typecheck && npm run build && npm run test:e2e`
|
||||
Expected: clean. E2E: a committed human edit shows green underline; an accepted Claude replace shows blue underline + purple struck hint; a pinned baseline shows nothing.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/editorProposalController.ts src/extension.ts test/trackChangesModel.test.ts test/e2e
|
||||
git commit -m "feat: author-colored committed track changes inline in the editor (reverse INV-32)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Overlap — stacked marks where both authors touch one span
|
||||
|
||||
**Files:**
|
||||
- Test: `test/trackChangesModel.test.ts` (preview overlap) + editor E2E (editor overlap)
|
||||
- Modify (if needed): `media/preview.css` (ensure `<ins>` nested in `<del>` and vice-versa render both marks)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing emitted classes.
|
||||
- Produces: a span both inserted (author A) and being deleted (author B) shows both A's underline and B's strikethrough.
|
||||
|
||||
- [ ] **Step 1: Editor overlap — verify decorations stack**
|
||||
|
||||
In the editor, an overlap arises when a committed insertion range (author A) is also covered by a pending proposal deletion (Claude). VS Code applies multiple decoration types to overlapping ranges, so the author-A insertion underline and the Claude deletion hint already coexist. Add an editor E2E asserting both decoration types are present on the overlap region. No code change expected; if the proposal's optimistic apply *replaces* the text (so A's range no longer exists), document that committed-insert + pending-delete overlap manifests as the proposal's struck original (Claude purple) adjacent to A's surviving insertion — assert that instead.
|
||||
|
||||
- [ ] **Step 2: Preview overlap — CSS nesting**
|
||||
|
||||
Add a CSS test fixture / assertion: a `<del class="cw-del-claude"><span class="cw-ins-human">…</span></del>` renders with both strikethrough (Claude purple) and underline (human green). Add to `media/preview.css` if the nested case collapses:
|
||||
|
||||
```css
|
||||
.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude,
|
||||
.cw-del-claude .cw-by-human, .cw-del-human .cw-by-claude { text-decoration: inherit; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `npm test && npm run test:e2e`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add media/preview.css test/trackChangesModel.test.ts test/e2e
|
||||
git commit -m "feat: overlap renders stacked author marks (insert + delete) in both panes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Sweep retired markup + full suite + manual smoke
|
||||
|
||||
**Files:**
|
||||
- `test/**`, `media/preview.css`, any code referencing retired classes.
|
||||
|
||||
- [ ] **Step 1: Grep for retired classes/behaviors**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rn "cw-add\b\|cw-del\b\|diffEditor.insertedTextBackground\|gitDecoration.deletedResourceForeground" src/ media/ test/
|
||||
```
|
||||
Reconcile every hit: `cw-add`/`cw-del` (without `-author`) only legitimately remain in `#cw-summary`. The editor's old semantic ThemeColors are gone (replaced in Task 6).
|
||||
|
||||
- [ ] **Step 2: Full unit + typecheck + build**
|
||||
|
||||
Run: `npm run typecheck && npm test && npm run build`
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 3: Full E2E**
|
||||
|
||||
Run: `npm run test:e2e`
|
||||
Expected: all green (update any remaining old-class assertions).
|
||||
|
||||
- [ ] **Step 4: Manual smoke (both panes)**
|
||||
|
||||
Open the sandbox doc, make a human edit + ask Claude to edit. Confirm: editor shows green/blue underlines + purple/red struck hints; preview shows the same; unchanged text plain in both; pin baseline → both panes clean; accept a proposal → it recolors as a committed change until re-pin.
|
||||
|
||||
- [ ] **Step 5: Commit + open PR**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: sweep retired semantic-diff markup; full suite green"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:**
|
||||
- Model (style=op, color=author) → Tasks 1-4 (preview), 6-7 (editor). ✓
|
||||
- Human green/red + Claude blue/purple, exact hexes → Global Constraints + Task 4 CSS + Task 6 decorations. ✓
|
||||
- Both panes → Phase 1 (preview) + Phase 2 (editor). ✓
|
||||
- Reverses INV-32 → Task 7. ✓
|
||||
- Both overlap directions, stacked → Task 8. ✓
|
||||
- Color = changed-since-baseline; pin→clean preserved → Task 2 (unchanged plain), Task 7 (`reason === "pinned"` guard). ✓
|
||||
- Deletion authorship = adjacency heuristic; standalone neutral → Task 1 (`wordDiffByAuthor` cluster author + `cw-del-none`), Task 7 (`authorAt(h.start)`). ✓
|
||||
- Components: trackChangesModel, editorProposalController, attributionController (read via spansFor), preview.css, extension.ts wiring → covered. ✓
|
||||
- Testing: unit (pure render), editor decorations (E2E), host E2E, retire old assertions → Tasks 1-3, 5, 7-9. ✓
|
||||
|
||||
**2. Placeholder scan:** Task 7 Step 2 intentionally shows a *wrong* placeholder (`hunkOriginal`/`original`) and immediately corrects it in the implementation note + Step 3 unit test that pins the exact offsets — this is a guided derivation, not an unresolved placeholder. No other TBD/TODO.
|
||||
|
||||
**3. Type consistency:** `AuthorKind` (`"claude" | "human"`) used throughout; decoration maps keyed `"human" | "claude"`; `authorAt` returns `AuthorKind | null` → `?? "human"`/`?? "none"` at every call site; `ProposalView.author?: AuthorKind`; `wordDiffByAuthor(beforeRaw, afterRaw, afterStart, spans)` signature matches both its test and its `renderReview` call site.
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
(Filled in after you review.)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,526 @@
|
||||
# #70 — INV-5 Reject-After-Interior-Edit Gap Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `✗ Reject` on an optimistically-applied proposal restores the retained original (INV-5) even after the writer typed inside the pending range — and when it genuinely cannot locate the applied span, it fails loudly instead of reporting success while leaving the proposed text behind.
|
||||
|
||||
**Architecture:** Issue #70's fix direction **(a)** with an honest **(b)** fallback. A new per-doc `appliedSpans: Map<proposalId, OffsetRange>` becomes the real F12 applied-range bookkeeping: written at optimistic-apply time, re-synced whenever the exact anchor resolves at `renderAll`, shifted through interior-safe buffer edits (`shift()`), and **deleted (distrusted)** when an edit straddles a span boundary (e.g. a whole-buffer external replace — a shifted range clamps to garbage there, and restoring at a guessed offset would corrupt the doc, INV-11's "never applied by guess"). `revertInPlace` then reverts via exact resolve → `appliedSpans` fallback → hard failure (warning, proposal kept, `false` returned). Direction (c) (fuzzy resolve) is rejected: it guesses. `rejectAll` orders by the same fallback and reports skips like `acceptAllProposals` does.
|
||||
|
||||
**Tech Stack:** TypeScript VS Code extension; `npm test` (vitest unit), `npm run test:e2e` (@vscode/test-electron host suite — `ProposalController` is vscode-coupled, so coverage is host E2E, matching every existing F12 test).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- INV-5: "Reject restores the retained original exactly" — the contract this fix enforces; `revertInPlace`'s doc comment already promises "reverts the whole block regardless of any in-place edits the human made".
|
||||
- INV-11: anchors are immutable for a proposal's life; stale text is flagged, **never applied by guess** — the fallback must only use a span whose tracking is provably continuous (no fuzzy resolve, no boundary-straddled ranges).
|
||||
- INV-12: accept/reject stay human-only gestures; no new mutation paths.
|
||||
- Keep `✓ Keep` (`finalizeInPlace`) behavior byte-identical — its lookup-by-id bypass of resolution is correct by design (issue "Contrast" note).
|
||||
- Existing test suites must stay green: 265+ unit, full host E2E (notably `proposals.test.ts` INV-11 stale-accept, `f12InlineDiff.test.ts`, `fullLoop.test.ts`).
|
||||
- Wiggleverse hygiene: no inline trailing comments in CLI blocks; commits cite the issue and carry the `Co-Authored-By` trailer.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `appliedSpans` bookkeeping + `revertInPlace` tracked-range fallback (the INV-5 repro)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/proposalController.ts` (DocState ~line 35, `ensureState` ~127, `optimisticApply` ~336, `finalizeInPlace` ~417, `revertInPlace` ~434, `renderAll` ~495, `onDidChange` ~529)
|
||||
- Test: `test/e2e/suite/f12InlineDiff.test.ts` (new suite appended)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `shift(range, edit)` / `resolve(text, fp)` from `src/anchorer.ts` (existing); `DocState.applied: Set<string>` (existing).
|
||||
- Produces: `DocState.appliedSpans: Map<string, OffsetRange>` (private bookkeeping; Tasks 2–3 rely on its distrust + ordering semantics); `revertInPlace(docPath, proposalId, opts?: { silent?: boolean }): Promise<boolean>` — signature gains the optional `opts` used by Task 3.
|
||||
|
||||
- [ ] **Step 1: Write the failing E2E test — the issue's repro sketch**
|
||||
|
||||
Append to `test/e2e/suite/f12InlineDiff.test.ts`:
|
||||
|
||||
```typescript
|
||||
// #70 (INV-5): rejecting a proposal AFTER typing inside its pending range must
|
||||
// still restore the retained original. The exact-substring anchor is orphaned by
|
||||
// the interior tweak (INV-1/INV-11) — the revert falls back to the F12
|
||||
// applied-range bookkeeping (appliedSpans), which shift-tracks the span through
|
||||
// interior edits.
|
||||
suite("F12 inline diff — #70 reject after interior edit (INV-5)", () => {
|
||||
test("revertInPlace restores the original after a tweak inside the pending range", async () => {
|
||||
const ORIGINAL = "The quick brown fox jumps.";
|
||||
const { doc } = await freshDoc("docs/s70-reject-tweaked.md", `# T\n\n${ORIGINAL}\n`);
|
||||
const api = await getApi();
|
||||
const p = api.proposalController;
|
||||
const docKey = p.keyFor(doc);
|
||||
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
|
||||
const id = await p.propose(doc, fp, "The rapid brown fox jumps.",
|
||||
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
||||
await p.optimisticApply(doc, id!);
|
||||
await settle();
|
||||
assert.ok(doc.getText().includes("The rapid brown fox jumps."), "optimistically applied");
|
||||
// Human types INSIDE the pending range → orphans the exact anchor (INV-11).
|
||||
const at = doc.getText().indexOf("rapid");
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + "rapid".length)), "RAPID-ish");
|
||||
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
|
||||
await settle();
|
||||
assert.strictEqual(p.listProposals(doc).find((v) => v.id === id)!.anchorStart, null, "anchor orphaned by the tweak");
|
||||
// ✗ Reject: must restore the retained original EXACTLY (INV-5), not skip.
|
||||
assert.ok(await p.revertInPlace(docKey, id!), "reject reports success");
|
||||
await settle();
|
||||
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
|
||||
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
|
||||
});
|
||||
|
||||
test("rejectByIdInPlace routes the tweaked-applied case the same way", async () => {
|
||||
const ORIGINAL = "A steady closing line.";
|
||||
const { doc } = await freshDoc("docs/s70-reject-route.md", `# T\n\n${ORIGINAL}\n`);
|
||||
const api = await getApi();
|
||||
const p = api.proposalController;
|
||||
const docKey = p.keyFor(doc);
|
||||
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
|
||||
const id = await p.propose(doc, fp, "A wobbly closing line.",
|
||||
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
||||
await p.optimisticApply(doc, id!);
|
||||
await settle();
|
||||
const at = doc.getText().indexOf("wobbly");
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at)), "very-");
|
||||
assert.ok(await vscode.workspace.applyEdit(we), "interior insertion applied");
|
||||
await settle();
|
||||
assert.ok(await p.rejectByIdInPlace(docKey, id!), "gesture-level reject succeeds");
|
||||
await settle();
|
||||
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the new suite to verify it fails**
|
||||
|
||||
```bash
|
||||
npm run test:e2e 2>&1 | grep -A 3 "#70"
|
||||
```
|
||||
|
||||
Expected: both tests FAIL — first on `doc.getText()` still holding the tweaked replacement (`RAPID-ish`) after a `true` return (the silent-skip bug), i.e. the "original restored exactly" assertion.
|
||||
|
||||
- [ ] **Step 3: Implement `appliedSpans` + the fallback**
|
||||
|
||||
In `src/proposalController.ts`:
|
||||
|
||||
(1) `DocState` gains the map (after `applied`):
|
||||
|
||||
```typescript
|
||||
/** ids already optimistically applied to the buffer (so the trigger doesn't re-apply). */
|
||||
applied: Set<string>;
|
||||
/**
|
||||
* #70 (INV-5): the F12 applied-range bookkeeping — proposal id -> the applied
|
||||
* span's CURRENT buffer range. Written at optimistic-apply, re-synced whenever
|
||||
* the exact anchor resolves (renderAll), shifted through interior-safe edits,
|
||||
* and DELETED (distrusted) when an edit straddles a span boundary — a clamped
|
||||
* range is a guess, and stale text is never applied by guess (INV-11). Lets
|
||||
* Reject restore the original after the human types INSIDE the pending range
|
||||
* (which orphans the exact-substring anchor).
|
||||
*/
|
||||
appliedSpans: Map<string, OffsetRange>;
|
||||
```
|
||||
|
||||
(2) `ensureState` initializes it: `appliedSpans: new Map(),` after `applied: new Set(),`.
|
||||
|
||||
(3) `optimisticApply` records the span where it already re-anchors (after `state.applied.add(proposalId);` and the `appliedStart`/`appliedEnd` computation — move the `add` down beside it or record right after the existing lines):
|
||||
|
||||
```typescript
|
||||
state.applied.add(proposalId);
|
||||
// Re-anchor to the applied text now in the buffer (its start is unchanged; its
|
||||
// end shifts by the net length delta of the replacement).
|
||||
const appliedStart = resolved.start;
|
||||
const appliedEnd = appliedStart + proposal.replacement.length;
|
||||
state.appliedSpans.set(proposalId, { start: appliedStart, end: appliedEnd });
|
||||
```
|
||||
|
||||
(4) `renderAll`'s resolved branch re-syncs (covers the prior-session-applied reload, where the in-memory map starts empty):
|
||||
|
||||
```typescript
|
||||
} else {
|
||||
this.recordProposal(state, proposal, resolved, true);
|
||||
if (state.applied.has(proposal.id)) state.appliedSpans.set(proposal.id, resolved);
|
||||
}
|
||||
```
|
||||
|
||||
(5) `onDidChange` maintains it — interior-safe edits shift, boundary-straddling edits distrust. Replace the method body:
|
||||
|
||||
```typescript
|
||||
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||
const state = this.docs.get(this.keyOf(e.document));
|
||||
if (!state || (state.live.size === 0 && state.appliedSpans.size === 0)) return;
|
||||
for (const change of e.contentChanges) {
|
||||
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
|
||||
for (const [id, range] of state.live) state.live.set(id, shift(range, edit));
|
||||
// #70: an edit fully inside a tracked applied span (or fully outside it)
|
||||
// keeps the span meaningful; one straddling a boundary — e.g. a whole-buffer
|
||||
// external replace — makes the shifted range a clamped guess, so the span
|
||||
// is distrusted (deleted) rather than reverted-to by guess (INV-11).
|
||||
for (const [id, range] of state.appliedSpans) {
|
||||
const outside = edit.end <= range.start || edit.start >= range.end;
|
||||
const inside = edit.start >= range.start && edit.end <= range.end;
|
||||
if (outside || inside) state.appliedSpans.set(id, shift(range, edit));
|
||||
else state.appliedSpans.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(6) `revertInPlace` — the fix itself. Replace the method (keep its position; doc comment updated to describe the layered lookup; `opts` is consumed by Task 3):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* F12/#64 (INV-51): REJECT an optimistically-applied proposal — replace its live
|
||||
* applied span with the stored `original`, then clear it. Reverts the whole block
|
||||
* regardless of any in-place edits the human made to the inserted text: when an
|
||||
* interior tweak has orphaned the exact anchor (INV-11), the revert falls back to
|
||||
* the shift-tracked applied span (#70, INV-5). When neither locates the span
|
||||
* (e.g. a boundary-straddling external rewrite distrusted it), the reject FAILS
|
||||
* loudly — warning shown, proposal kept, `false` returned — instead of silently
|
||||
* leaving the proposed text in the buffer. Undo (or ✓ Keep) remains the way out.
|
||||
*/
|
||||
async revertInPlace(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
|
||||
const hit = this.byId(docPath, proposalId);
|
||||
if (!hit) return false;
|
||||
const document = this.openDoc(hit.state);
|
||||
if (!document) return false;
|
||||
// Never optimistically applied → nothing of ours is in the buffer; clearing
|
||||
// the record IS the reject (the legacy pending-only path).
|
||||
if (hit.proposal.original === undefined) {
|
||||
this.store.update(docPath, (a) => removeProposal(a, proposalId));
|
||||
hit.state.applied.delete(proposalId);
|
||||
hit.state.appliedSpans.delete(proposalId);
|
||||
this.renderAll(document);
|
||||
return true;
|
||||
}
|
||||
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
|
||||
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
|
||||
const span = resolved !== "orphaned" ? resolved : hit.state.appliedSpans.get(proposalId);
|
||||
if (!span) {
|
||||
if (!opts?.silent) {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: this proposal's applied text can't be located (it changed or moved) — undo your edits, or Keep it to leave the buffer as-is (it is never reverted by guess).",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
we.replace(
|
||||
document.uri,
|
||||
new vscode.Range(document.positionAt(span.start), document.positionAt(span.end)),
|
||||
hit.proposal.original,
|
||||
);
|
||||
if (!(await vscode.workspace.applyEdit(we))) return false;
|
||||
this.store.update(docPath, (a) => removeProposal(a, proposalId));
|
||||
hit.state.applied.delete(proposalId);
|
||||
hit.state.appliedSpans.delete(proposalId);
|
||||
this.renderAll(document);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
(7) `finalizeInPlace` cleans the map beside its existing `applied.delete`:
|
||||
|
||||
```typescript
|
||||
hit.state.applied.delete(proposalId);
|
||||
hit.state.appliedSpans.delete(proposalId);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Typecheck + unit tests + the new E2E**
|
||||
|
||||
```bash
|
||||
npm run typecheck && npm test
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Expected: typecheck clean; all unit tests PASS (no unit test touches `ProposalController`); host E2E PASS including the two new #70 tests and all pre-existing F12/proposals/fullLoop suites.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/proposalController.ts test/e2e/suite/f12InlineDiff.test.ts
|
||||
git commit -m "fix(#70): reject after an interior tweak restores the original via tracked applied span (INV-5)
|
||||
|
||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: The honest hard-failure path (fix direction (b) for the residual orphan)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/proposalController.ts` (only if Step 2 exposes a gap — the Task 1 code already implements the path)
|
||||
- Test: `test/e2e/suite/f12InlineDiff.test.ts` (extend the #70 suite)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1's `appliedSpans` distrust semantics and `revertInPlace` failure contract.
|
||||
- Produces: nothing new — locks the contract with a regression test.
|
||||
|
||||
- [ ] **Step 1: Write the failing/locking E2E test**
|
||||
|
||||
Append inside the `#70` suite from Task 1:
|
||||
|
||||
> **Execution note:** the first cut of this test used a whole-buffer rewrite as the
|
||||
> distrusting edit and FAILED — VS Code minimizes workspace edits before change
|
||||
> events fire, so a rewrite sharing a prefix/suffix with the old text decomposes
|
||||
> into interior hunks the span legitimately survives (and the revert then correctly
|
||||
> restores the original in place). The distrusting edit must straddle a span
|
||||
> boundary even after minimization — a deletion from outside the span into its
|
||||
> interior, as below.
|
||||
|
||||
```typescript
|
||||
// Fix direction (b): when the tracked span itself is distrusted (an edit
|
||||
// straddled its boundary — here a deletion running from the heading into the
|
||||
// span's interior), the reject FAILS honestly: warning path, proposal kept,
|
||||
// buffer untouched. Silent-skip-and-report-success (the #70 bug) must not come
|
||||
// back; reverting at a clamped guessed offset (INV-11) must not either.
|
||||
// (A boundary-straddling DELETION is used because VS Code minimizes workspace
|
||||
// edits before change events fire — a wholesale rewrite sharing a prefix/suffix
|
||||
// decomposes into interior hunks the span legitimately survives.)
|
||||
test("reject fails loudly — proposal kept, buffer untouched — when the span is distrusted", async () => {
|
||||
const ORIGINAL = "Sentence one stands here.";
|
||||
const { doc } = await freshDoc("docs/s70-reject-distrust.md", `# T\n\n${ORIGINAL}\n`);
|
||||
const api = await getApi();
|
||||
const p = api.proposalController;
|
||||
const docKey = p.keyFor(doc);
|
||||
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
|
||||
const id = await p.propose(doc, fp, "Sentence ONE stands here.",
|
||||
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
||||
await p.optimisticApply(doc, id!);
|
||||
await settle();
|
||||
// Delete from inside the heading through the middle of the applied span:
|
||||
// the edit straddles the span's start boundary → the tracked span is
|
||||
// distrusted AND the exact anchor no longer resolves.
|
||||
const mid = doc.getText().indexOf("ONE");
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
we.replace(doc.uri, new vscode.Range(doc.positionAt(2), doc.positionAt(mid + 1)), "");
|
||||
assert.ok(await vscode.workspace.applyEdit(we), "boundary-straddling deletion applied");
|
||||
await settle();
|
||||
const before = doc.getText();
|
||||
assert.strictEqual(await p.revertInPlace(docKey, id!), false, "reject reports failure (no silent success)");
|
||||
await settle();
|
||||
assert.strictEqual(doc.getText(), before, "buffer untouched — never reverted by guess (INV-11)");
|
||||
assert.ok(p.listProposals(doc).some((v) => v.id === id), "proposal kept (not silently dropped)");
|
||||
// Cleanup for later tests: the never-locatable husk is discarded via the
|
||||
// plain record-only reject.
|
||||
assert.strictEqual(p.rejectById(docKey, id!), true);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it**
|
||||
|
||||
```bash
|
||||
npm run test:e2e 2>&1 | grep -B 2 -A 5 "distrusted"
|
||||
```
|
||||
|
||||
Expected: PASS if Task 1's implementation is complete (this is the locking regression); if it FAILS, the failure pinpoints which leg (distrust bookkeeping vs. failure return vs. proposal retention) is wrong — fix `src/proposalController.ts` accordingly, not the test.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add test/e2e/suite/f12InlineDiff.test.ts src/proposalController.ts
|
||||
git commit -m "test(#70): lock the honest hard-failure reject path (distrusted span → warn, keep, false)
|
||||
|
||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `rejectAll` parity — tweaked proposals revert too, skips are reported
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/proposalController.ts` (`rejectAll` ~line 461), `src/extension.ts` (`cowriting.rejectAllProposals` handler ~line 220)
|
||||
- Test: `test/e2e/suite/f12InlineDiff.test.ts` (extend the #70 suite)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1's `revertInPlace(docPath, id, opts?: { silent?: boolean })` and `appliedSpans`.
|
||||
- Produces: `rejectAll(document): Promise<{ reverted: number; skipped: number }>` — additive `skipped` field (existing `{ reverted }` destructurings stay valid).
|
||||
|
||||
- [ ] **Step 1: Write the failing E2E test**
|
||||
|
||||
Append inside the `#70` suite:
|
||||
|
||||
```typescript
|
||||
// rejectAll must revert a tweaked (orphaned-anchor) proposal via the same
|
||||
// tracked-span fallback, ordered descending by that span so earlier reverts
|
||||
// never shift later ones — and must count what it could not revert.
|
||||
test("rejectAll reverts tweaked proposals via the tracked span and reports skips", async () => {
|
||||
const { doc } = await freshDoc("docs/s70-rejall-tweak.md", "# T\n\nOne aaa.\n\nTwo bbb.\n");
|
||||
const api = await getApi();
|
||||
const p = api.proposalController;
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||
await settle();
|
||||
for (const id of ids) await p.optimisticApply(doc, id);
|
||||
await settle();
|
||||
// Tweak INSIDE the first applied block → its exact anchor orphans.
|
||||
const at = doc.getText().indexOf("AAA");
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 3)), "AAAZ");
|
||||
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
|
||||
await settle();
|
||||
const { reverted, skipped } = await p.rejectAll(doc);
|
||||
await settle();
|
||||
assert.strictEqual(reverted, ids.length, "ALL proposals reverted, tweaked one included");
|
||||
assert.strictEqual(skipped, 0, "nothing skipped");
|
||||
assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document fully restored (INV-5)");
|
||||
assert.strictEqual(p.listProposals(doc).length, 0, "all cleared");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to verify the current gap**
|
||||
|
||||
```bash
|
||||
npm run test:e2e 2>&1 | grep -B 2 -A 5 "rejectAll reverts tweaked"
|
||||
```
|
||||
|
||||
Expected: FAIL — today the tweaked proposal sorts as an orphan (`start: -1`, reverted last), and after the first successful revert's `renderAll` the old code silently removes it without restoring (`document fully restored` assertion fails), and `skipped` does not exist on the return type (typecheck catches first — that is the same failure).
|
||||
|
||||
- [ ] **Step 3: Implement — order by the fallback span, count skips, report them**
|
||||
|
||||
Replace `rejectAll` in `src/proposalController.ts`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* F12/#64 (INV-53): reject EVERY pending proposal on a document — revert each in
|
||||
* DESCENDING span order (so an earlier revert never shifts a later one's
|
||||
* offsets), symmetric with #46's accept-all. #70: a tweaked proposal whose exact
|
||||
* anchor is orphaned orders (and reverts) by its shift-tracked applied span;
|
||||
* one with no locatable span is SKIPPED — counted, never guessed (INV-11) —
|
||||
* and the per-proposal warning is suppressed in favor of the batch report.
|
||||
*/
|
||||
async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number; skipped: number }> {
|
||||
if (!this.isTracked(document)) return { reverted: 0, skipped: 0 };
|
||||
const docPath = this.keyOf(document);
|
||||
const state = this.ensureState(document);
|
||||
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
|
||||
const text = document.getText();
|
||||
const ordered = state.artifact.proposals
|
||||
.map((p) => {
|
||||
const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
|
||||
const r = fp ? resolve(text, fp) : "orphaned";
|
||||
const start = r !== "orphaned" ? r.start : state.appliedSpans.get(p.id)?.start ?? -1;
|
||||
return { id: p.id, start };
|
||||
})
|
||||
.sort((a, b) => b.start - a.start);
|
||||
let reverted = 0;
|
||||
let skipped = 0;
|
||||
for (const it of ordered) {
|
||||
if (await this.revertInPlace(docPath, it.id, { silent: true })) reverted++;
|
||||
else skipped++;
|
||||
}
|
||||
return { reverted, skipped };
|
||||
}
|
||||
```
|
||||
|
||||
And in `src/extension.ts`, the `cowriting.rejectAllProposals` handler mirrors accept-all's skip note:
|
||||
|
||||
```typescript
|
||||
const { reverted, skipped } = await proposalController.rejectAll(doc);
|
||||
if (reverted === 0 && skipped === 0) return;
|
||||
const skipNote = skipped > 0 ? `, ${skipped} skipped (applied text not locatable — undo your edits or Keep)` : "";
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}${skipNote}.`,
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Typecheck + full test run**
|
||||
|
||||
```bash
|
||||
npm run typecheck && npm test
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Expected: all PASS — including the pre-existing `rejectAll` E2E tests (`f12InlineDiff` "rejectAll reverts every pending proposal", "control parity", `proposals.test.ts` cleanup calls), whose `{ reverted }` destructuring is unaffected by the additive field.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/proposalController.ts src/extension.ts test/e2e/suite/f12InlineDiff.test.ts
|
||||
git commit -m "fix(#70): rejectAll reverts tweaked proposals via the tracked span; skips are counted and reported
|
||||
|
||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Close the loop — fullLoop E2E rejects a tweaked proposal (the deliberate avoidance falls)
|
||||
|
||||
**Files:**
|
||||
- Modify: `test/e2e/suite/fullLoop.test.ts` (the PUC-2 reject section, ~line 144)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the shipped Task 1 behavior; existing fullLoop fixtures (`rejectId`, `REJECT_REPLACEMENT`, `ORIG_REJECT`).
|
||||
- Produces: nothing — upgrades the flagship E2E so the gap can't silently regress.
|
||||
|
||||
- [ ] **Step 1: Tweak inside the reject proposal's range before rejecting**
|
||||
|
||||
In `test/e2e/suite/fullLoop.test.ts`, immediately before the existing line
|
||||
`assert.ok(await api.proposalController.revertInPlace(docKey, rejectId), "reject reverts in place");`,
|
||||
insert:
|
||||
|
||||
```typescript
|
||||
// #70 (INV-5): the reject leg now ALSO takes an interior tweak first — the
|
||||
// original deliberately rejected only an un-tweaked proposal because the
|
||||
// pre-fix revert silently skipped an orphaned anchor. The tweak orphans the
|
||||
// exact anchor; the revert must restore ORIG_REJECT via the tracked span.
|
||||
const rejAt = doc.getText().indexOf(REJECT_REPLACEMENT);
|
||||
assert.ok(rejAt >= 0, "reject proposal's applied text present");
|
||||
const rejTweak = new vscode.WorkspaceEdit();
|
||||
rejTweak.replace(doc.uri, new vscode.Range(doc.positionAt(rejAt + 2), doc.positionAt(rejAt + 2)), "x");
|
||||
assert.ok(await vscode.workspace.applyEdit(rejTweak), "human tweak inside the reject proposal's range");
|
||||
await settle();
|
||||
assert.strictEqual(
|
||||
api.proposalController.listProposals(doc).find((v) => v.id === rejectId)!.anchorStart,
|
||||
null,
|
||||
"tweak orphans the reject proposal's exact anchor (INV-11)",
|
||||
);
|
||||
```
|
||||
|
||||
(The existing assertions right after — proposal cleared, `ORIG_REJECT` restored, `REJECT_REPLACEMENT` gone — now verify the #70 path. If `REJECT_REPLACEMENT` is a multi-word string whose slice at `+2` splits a word the later `!doc.getText().includes(REJECT_REPLACEMENT)` assertion still holds; the `includes(ORIG_REJECT)` restore assertion is the INV-5 check.)
|
||||
|
||||
- [ ] **Step 2: Run the fullLoop suite**
|
||||
|
||||
```bash
|
||||
npm run test:e2e 2>&1 | grep -B 2 -A 8 "fullLoop\|full-loop\|full loop"
|
||||
```
|
||||
|
||||
Expected: PASS end-to-end (would FAIL on unfixed code: the buffer would keep the tweaked `REJECT_REPLACEMENT`, so `includes(ORIG_REJECT)` fails).
|
||||
|
||||
- [ ] **Step 3: Full verification sweep**
|
||||
|
||||
```bash
|
||||
npm run typecheck && npm test && npm run test:e2e
|
||||
```
|
||||
|
||||
Expected: everything green.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add test/e2e/suite/fullLoop.test.ts
|
||||
git commit -m "test(#70): fullLoop reject leg now tweaks inside the pending range first (INV-5 end-to-end)
|
||||
|
||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Post-execution review wave (not in the original tasks)
|
||||
|
||||
A high-effort multi-agent branch review after Task 4 confirmed 10 findings; all
|
||||
but one were fixed in a follow-up commit on this branch ("harden the
|
||||
tracked-span revert per branch review"): pure `shiftTracked` in `anchorer.ts`
|
||||
(+11 unit tests; insertion-at-span-start lands before the span), tracked spans
|
||||
cleared on document close, rebuild-only resync at `renderAll`, `guard.isReadOnly`
|
||||
on `revertInPlace`/`finalizeInPlace`, a "Discard proposal (leave text)" action on
|
||||
the hard-fail warning, CodeLens pair anchored at the tracked span (+1 E2E),
|
||||
QuickPick batch menu routed through the reporting commands, and one
|
||||
`clearProposal` helper (also fixes `reject()`'s stale `applied`/`appliedSpans`).
|
||||
The remaining finding — `resolve()` accepts a single exact occurrence without a
|
||||
context check, so a duplicated block can hijack any anchor consumer — pre-dates
|
||||
this fix and is filed as its own issue.
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Issue coverage:** direction (a) → Task 1 (`appliedSpans` fallback); direction (b) → Task 2 (hard failure, warn, keep); direction (c) explicitly rejected (guessing violates INV-11). The issue's repro sketch is Task 1's test verbatim; the "full-loop E2E deliberately rejects only an un-tweaked proposal" note is retired by Task 4; `rejectAll` (same `revertInPlace` seam) is covered by Task 3.
|
||||
- **Type consistency:** `revertInPlace(docPath, proposalId, opts?: { silent?: boolean })` defined in Task 1, consumed in Task 3; `rejectAll` returns `{ reverted, skipped }` (additive); `appliedSpans: Map<string, OffsetRange>` used in Tasks 1 and 3.
|
||||
- **Contrast guard:** `finalizeInPlace` untouched except symmetric `appliedSpans.delete` cleanup — the Keep bypass stays.
|
||||
@@ -0,0 +1,342 @@
|
||||
# #71 Surface Polish Batch Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Land the six batched Minor findings from the native-surfaces migration's final whole-branch review (issue #71) — misleading `pin()` warning, orphaned command declarations, a wrong when-clause key, README F3/banner drift, an ungated palette entry, and an uncleared debounce timer.
|
||||
|
||||
**Architecture:** Six independent point fixes across `src/diffViewController.ts`, `src/gitBaseline.ts`, `package.json`, and `README.md`. No new modules, no model/persistence changes, no new invariants. Verification is the existing suites (all six items are copy/declaration/cleanup changes with no unit-testable pure surface — the unit suite is vscode-free and these all live at the vscode boundary; E2E cannot observe warning-message text or palette visibility).
|
||||
|
||||
**Tech Stack:** VS Code extension (TypeScript, esbuild CJS bundle), vitest unit suite (`npm test`), `@vscode/test-electron` host E2E (`npm run test:e2e`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Work happens on branch `worktree-s71-surface-polish` in the worktree at `.claude/worktrees/s71-surface-polish` (canonical checkout is occupied by concurrent session 0062 — never touch it).
|
||||
- Commits cite #71, read like surrounding history, and carry the `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` trailer.
|
||||
- Warning copy for the not-coediting case is exactly `"Run ✦ Coedit this Document with Claude first."` — the string `src/threadController.ts:181` already uses. Do not coin a variant.
|
||||
- No inline trailing comments on shell commands (wgl `no-inline-cli-comments`).
|
||||
- Final gate before PR: `npm test` (unit), `npm run typecheck`, `npm run build`, `npm run test:e2e` all green.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `pin()` explicit not-coediting branch (item 1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/diffViewController.ts:145-155`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `this.modes: Map<string, "head" | "snapshot">` (set only by `establish()`).
|
||||
- Produces: nothing new — same `pin(document): void` signature; only the warning copy forks.
|
||||
|
||||
`pin()` currently emits "this document is git-tracked — commit to advance the baseline" for **any** non-snapshot mode, including `undefined` (a document that never established, i.e. is not being coedited) — a wrong message for that case.
|
||||
|
||||
- [ ] **Step 1: Fork the guard on `undefined`**
|
||||
|
||||
Replace lines 145–155 (the whole `pin` method) with:
|
||||
|
||||
```ts
|
||||
pin(document: vscode.TextDocument): void {
|
||||
if (!this.isDiffable(document)) return;
|
||||
const key = this.uriKey(document);
|
||||
const mode = this.modes.get(key);
|
||||
if (mode === undefined) {
|
||||
// never established — the document is not being coedited (#71 item 1)
|
||||
void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first.");
|
||||
return;
|
||||
}
|
||||
if (mode !== "snapshot") {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: this document is git-tracked — commit to advance the baseline.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.capture(document, "pinned");
|
||||
}
|
||||
```
|
||||
|
||||
Keep the existing doc comment above the method unchanged.
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: clean exit.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/diffViewController.ts
|
||||
git commit -m "fix: markReviewed on a never-established doc says 'coedit first', not 'git-tracked' (#71 item 1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: delete orphaned `acceptProposal` / `rejectProposal` declarations (item 2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (two `contributes.commands` entries ~85-93, two `contributes.menus.commandPalette` entries ~180-187)
|
||||
|
||||
**Interfaces:** none — pure declaration removal. `test/e2e/suite-no-workspace/noWorkspace.test.ts:42-43` already asserts these commands are **not registered** at runtime; nothing registers them in `src/`.
|
||||
|
||||
- [ ] **Step 1: Delete the two command declarations**
|
||||
|
||||
In `contributes.commands`, delete both objects:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "cowriting.acceptProposal",
|
||||
"title": "✓ Accept Proposal",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.rejectProposal",
|
||||
"title": "✗ Reject Proposal",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Delete their palette-hiding menu entries**
|
||||
|
||||
In `contributes.menus.commandPalette`, delete both objects (they exist only to hide the now-deleted declarations):
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "cowriting.acceptProposal",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.rejectProposal",
|
||||
"when": "false"
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify no dangling references**
|
||||
|
||||
Run: `grep -rn "acceptProposal\|rejectProposal" package.json src/`
|
||||
Expected: no matches (the only remaining references are the retirement assertions in `test/e2e/suite-no-workspace/noWorkspace.test.ts`).
|
||||
|
||||
Run: `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('valid json')"`
|
||||
Expected: `valid json`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "chore: delete orphaned acceptProposal/rejectProposal declarations (#71 item 2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `openReviewPreview` editor/title when-key (item 3)
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json:260` (the `editor/title` menu entry for `cowriting.openReviewPreview`)
|
||||
|
||||
**Interfaces:** none — one-token when-clause fix. Title menus key off the resource, so `resourceLangId` is correct (every sibling `editor/title` entry already uses it); `editorLangId` is the odd one out.
|
||||
|
||||
- [ ] **Step 1: Fix the key**
|
||||
|
||||
In `contributes.menus."editor/title"`, change:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "cowriting.openReviewPreview",
|
||||
"when": "editorLangId == markdown",
|
||||
"group": "navigation@9"
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "cowriting.openReviewPreview",
|
||||
"when": "resourceLangId == markdown",
|
||||
"group": "navigation@9"
|
||||
}
|
||||
```
|
||||
|
||||
(Only the `editor/title` entry. The `commandPalette` entries elsewhere legitimately use `editorLangId`; the `editor/title/context` and `explorer/context` entries already use `resourceLangId`.)
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "fix: openReviewPreview title-menu when uses resourceLangId like its siblings (#71 item 3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: gate the `cowriting.createThread` palette entry (item 5)
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (`contributes.menus.commandPalette`)
|
||||
|
||||
**Interfaces:** none — `createThreadOnSelection` (`src/threadController.ts:197-201`) already returns `undefined` silently without a selection / coediting / authorable scheme; the palette entry is currently unconditionally visible, so the command silently no-ops from the palette. Fix = gate the palette entry with the **same when-clause its `editor/context` entry uses** (`package.json:290-293`), so the palette only offers it when it can act — consistent with how `markReviewed`'s palette gate was tightened at merge.
|
||||
|
||||
- [ ] **Step 1: Add the palette gate**
|
||||
|
||||
In `contributes.menus.commandPalette`, insert after the `cowriting.proposeAgentEdit` entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "cowriting.createThread",
|
||||
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting"
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify JSON**
|
||||
|
||||
Run: `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('valid json')"`
|
||||
Expected: `valid json`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "fix: gate createThread palette entry on selection+coediting like its context-menu entry (#71 item 5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: clear the `gitBaseline` reflog debounce timer in `dispose()` (item 6)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/gitBaseline.ts:113-126` (`watchReflog`)
|
||||
|
||||
**Interfaces:** none — same disposable contract; the pushed disposable additionally clears the pending debounce timeout so no one-shot `repo.status()` fires post-dispose.
|
||||
|
||||
- [ ] **Step 1: Clear the timer in the pushed disposable**
|
||||
|
||||
In `watchReflog`, change:
|
||||
|
||||
```ts
|
||||
const watcher = fs.watch(reflog, () => {
|
||||
if (pending) clearTimeout(pending);
|
||||
pending = setTimeout(() => void repo.status().catch(() => {}), 150);
|
||||
});
|
||||
this.disposables.push({ dispose: () => watcher.close() });
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```ts
|
||||
const watcher = fs.watch(reflog, () => {
|
||||
if (pending) clearTimeout(pending);
|
||||
pending = setTimeout(() => void repo.status().catch(() => {}), 150);
|
||||
});
|
||||
this.disposables.push({
|
||||
dispose: () => {
|
||||
watcher.close();
|
||||
if (pending) clearTimeout(pending);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: clean exit.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/gitBaseline.ts
|
||||
git commit -m "fix: clear reflog debounce timer on GitBaselineWatcher dispose (#71 item 6)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: README F3 drift + stale chord notes in superseded banners (item 4)
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` (F3 section ~106, F7 banner ~230-233, F10 banner ~313-317)
|
||||
|
||||
**Interfaces:** none — docs only. Current reality the banner must state: `Cowriting: Toggle Attribution` no longer exists (annotations toggle via **Toggle Annotations**, How it works §6); "Ask Claude to Edit Selection" is palette-hidden (`package.json:201-203` — `"when": "false"`), superseded by **Ask Claude to Edit** (`Ctrl+Alt+E` / `Cmd+Alt+E`) and the comments-first **Ask Claude** (§3). `Ctrl+Alt+R` today is **Review Changes** (native diff), not any preview.
|
||||
|
||||
- [ ] **Step 1: Add the F3 banner**
|
||||
|
||||
Immediately after the `## F3 — Live human/Claude attribution (Feature #6)` heading (before the "As you and Claude coauthor…" paragraph), insert:
|
||||
|
||||
```markdown
|
||||
> **Commands superseded (native-surfaces migration).** The attribution **data
|
||||
> layer** below is current, but the Commands block is historical:
|
||||
> `Cowriting: Toggle Attribution` was retired — authorship/change coloring now
|
||||
> toggles via **Toggle Annotations** on a coedited document (How it works §6) —
|
||||
> and "Ask Claude to Edit Selection" is palette-hidden; use **Ask Claude to
|
||||
> Edit** (`Ctrl+Alt+E` / `Cmd+Alt+E`) or the comments-first **Ask Claude** (How
|
||||
> it works §3). Kept below as a historical record.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Chord note in the F7 banner**
|
||||
|
||||
Extend the F7 superseded banner's last sentence. Change:
|
||||
|
||||
```markdown
|
||||
> **Superseded (Task 8, native-surfaces migration).** The bespoke webview this
|
||||
> section describes was deleted; its authorship/change coloring lives on
|
||||
> **inside VS Code's own built-in Markdown preview** — see **How it works** §6
|
||||
> above. Kept below as a historical record of the pre-migration design.
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```markdown
|
||||
> **Superseded (Task 8, native-surfaces migration).** The bespoke webview this
|
||||
> section describes was deleted; its authorship/change coloring lives on
|
||||
> **inside VS Code's own built-in Markdown preview** — see **How it works** §6
|
||||
> above. Kept below as a historical record of the pre-migration design. (The
|
||||
> `Ctrl+Alt+R` chord below now opens the native **Review Changes** diff; the
|
||||
> annotated preview opens via **Open Cowriting Review Preview**, no chord.)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Chord note in the F10 banner**
|
||||
|
||||
Change the F10 banner's last sentence from:
|
||||
|
||||
```markdown
|
||||
> **How it works** above. Kept below as a historical record.
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```markdown
|
||||
> **How it works** above. Kept below as a historical record. (The `Ctrl+Alt+R`
|
||||
> chord below now opens the native **Review Changes** diff, not this panel.)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: F3 superseded-commands banner + stale-chord notes in F7/F10 banners (#71 item 4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: full verification + PR + merge
|
||||
|
||||
**Files:** none new.
|
||||
|
||||
- [ ] **Step 1: Full local gate**
|
||||
|
||||
Run each; all must be green:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run build
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Expected: unit suite passes (~250 tests), typecheck clean, build succeeds, host E2E passes (undo suites may self-skip via the #54 runtime probe — a loud warning, not a failure).
|
||||
|
||||
- [ ] **Step 2: Push branch + open PR**
|
||||
|
||||
```bash
|
||||
git push -u origin worktree-s71-surface-polish
|
||||
```
|
||||
|
||||
Open a PR against `main` on `benstull/vscode-cowriting-plugin` (Gitea, SSH remote) titled `Surface polish: batched Minor findings from the native-surfaces final review (#71)`; body lists the six items and cites the issue.
|
||||
|
||||
- [ ] **Step 3: Merge (squash) and confirm #71 auto-close/close**
|
||||
|
||||
Merge the PR; verify `main` carries the change and close #71 if the merge didn't.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Author-colored track changes (both panes) — design
|
||||
|
||||
**Date:** 2026-06-26
|
||||
**Status:** Design approved; ready for implementation planning
|
||||
**Mockups:** `.superpowers/brainstorm/15095-1782504559/content/panes-states-v2.html`
|
||||
|
||||
## Problem
|
||||
|
||||
Today the plugin uses two unrelated color systems:
|
||||
|
||||
- **The diff** (proposed/changed text in both panes) uses *semantic* colors —
|
||||
green = added, red = removed — regardless of who made the change.
|
||||
- **Authorship** (F3) uses *author* colors (blue = Claude, green = human) but
|
||||
only as a flat tint in the **preview** pane, and only on committed text, not
|
||||
as a diff.
|
||||
|
||||
So you cannot look at a change and immediately see **who** made it. The product
|
||||
goal is an experience like two humans collaborating in a track-changes document
|
||||
(Google Docs suggesting mode / Word track changes): every edit is a diff, and a
|
||||
glance tells you both *what changed* and *who changed it*.
|
||||
|
||||
## The model
|
||||
|
||||
Every change is rendered as a diff — insertions **and** deletions — where:
|
||||
|
||||
- **Style encodes the operation:** underline = inserted, strikethrough = removed.
|
||||
- **Color encodes the author.**
|
||||
|
||||
| | Added | Removed |
|
||||
| ------ | ---------------- | ---------------------- |
|
||||
| Human | green underline | red strikethrough |
|
||||
| Claude | blue underline | purple strikethrough |
|
||||
|
||||
Human keeps today's exact green/red. Claude gets blue/purple (blue is already
|
||||
Claude's established attribution hue; purple reads as a removal without colliding
|
||||
with human red).
|
||||
|
||||
**Color means "changed since the pinned baseline,"** not "authored forever." Once
|
||||
a change is accepted and the baseline re-pins, its marks clear and the text
|
||||
returns to plain. This preserves the existing pin→clean behavior (#48): a pinned,
|
||||
zero-diff document renders with no annotation.
|
||||
|
||||
### Concrete colors
|
||||
|
||||
Carry the existing human values; add Claude's pair. (Final hex values are a
|
||||
polish detail for implementation; these are the design intent.)
|
||||
|
||||
- Human added: `#3fb950` underline, bg `rgba(63,185,80,0.14)`
|
||||
- Human removed: `#f85149` strikethrough, bg `rgba(248,81,73,0.11)`
|
||||
- Claude added: `#58a6ff` underline, bg `rgba(88,166,255,0.15)`
|
||||
- Claude removed: `#bc8cff` strikethrough, bg `rgba(188,140,255,0.13)`
|
||||
|
||||
Each change run is a subtle author-hued background tint **plus** a solid 2px
|
||||
underline (insert) or strikethrough (delete) in the full author color — the solid
|
||||
line carries the signal even where backgrounds are faint.
|
||||
|
||||
## Both panes (decision A)
|
||||
|
||||
Author-colored track changes appear in **both** the main editor pane and the
|
||||
rendered preview pane.
|
||||
|
||||
- **Main editor pane:** gains full inline track-changes for *all*
|
||||
changes-since-baseline — human and Claude, committed and pending — not just
|
||||
pending proposals. **This reverses INV-32** ("editor fully clean") from F10:
|
||||
the editor was deliberately kept clean, with all who-wrote-what coloring living
|
||||
only in the preview. We are intentionally undoing that.
|
||||
- **Rendered preview pane:** shows the same model (it already renders a diff +
|
||||
authorship; this unifies them so ins/del runs are author-colored rather than
|
||||
semantically colored).
|
||||
|
||||
Pending Claude proposals keep their Accept/Reject affordance in both panes —
|
||||
CodeLens (`✓ Accept ▾ / ✗ Reject ▾`) in the editor, buttons in the preview.
|
||||
|
||||
## States (must all render correctly)
|
||||
|
||||
Base states: human-added, human-removed, Claude-added, Claude-removed.
|
||||
|
||||
**Overlap** — a single span both authors touched — stacks both marks:
|
||||
|
||||
- Human added it, Claude is removing it → green underline **+** purple strikethrough.
|
||||
- Claude added it, human is removing it → blue underline **+** red strikethrough.
|
||||
|
||||
Block-level: a whole added block carries the author's add treatment (left border +
|
||||
tint); a whole removed block carries the author's remove treatment (struck + tint).
|
||||
|
||||
Resting state: unchanged-since-baseline text renders plain. After Accept + re-pin,
|
||||
changed text returns to plain.
|
||||
|
||||
## Architecture
|
||||
|
||||
The core change is **fusing the currently-separate semantic-diff and authorship
|
||||
systems** so that every inserted/deleted run carries its author, and rendering
|
||||
colors by that author rather than by add/remove semantics.
|
||||
|
||||
Components affected (per the current code map):
|
||||
|
||||
- **`src/trackChangesModel.ts`** — `renderReview` / `renderOp` /
|
||||
`wordMergedMarkdown` / `colorByAuthor`. Today `<ins>`/`<del>` are emitted with
|
||||
semantic green/red classes independent of author. Change: ins/del runs become
|
||||
author-aware and emit author-colored classes; overlap emits a stacked-mark
|
||||
class.
|
||||
- **`src/editorProposalController.ts`** — today only pending proposals decorate,
|
||||
using semantic `ThemeColor`s. Change: per-author/op `TextEditorDecorationType`s
|
||||
(human-add/human-del/claude-add/claude-del + overlap), and decorate **all**
|
||||
changes-since-baseline, not just proposals (the INV-32 reversal).
|
||||
- **`src/attributionController.ts`** — supplies the per-character/per-run author
|
||||
spans the diff needs to attribute insertions (and, see below, deletions).
|
||||
- **`media/preview.css`** — recolor: keep human green/red, add Claude
|
||||
blue/purple, encode underline=insert / strikethrough=delete, add overlap
|
||||
(stacked-mark) classes for both directions.
|
||||
|
||||
## Open technical question (for the plan / tech-design stage)
|
||||
|
||||
**Deletion authorship.** Removed text is no longer in the buffer, so the renderer
|
||||
must know *who deleted it* to color the strikethrough.
|
||||
|
||||
- For **pending proposals**, the deleter is trivially Claude (the proposal owns
|
||||
the deletion).
|
||||
- For **committed deletions** (baseline → current), we must confirm the F3 seam /
|
||||
attribution model carries the **deleting actor**. F3 attributes
|
||||
*current-buffer* characters; deleted characters are gone. If the seam's edit
|
||||
events carry the actor that performed each removal, we attribute from that; if
|
||||
not, deriving deletion authorship is the main thing the implementation plan has
|
||||
to solve. Until resolved, a safe fallback is to color committed deletions by the
|
||||
**turn actor** that produced the diff hunk.
|
||||
|
||||
This is the one genuinely open architectural item; everything else is
|
||||
presentation.
|
||||
|
||||
## Out of scope (YAGNI)
|
||||
|
||||
- More than two authors / arbitrary per-collaborator palettes. Two roles
|
||||
(human, Claude) only.
|
||||
- User-configurable colors / theme settings. Ship one good scheme.
|
||||
- Changing accept/reject *mechanics* — this is purely how changes are
|
||||
**visualized**; the F4 proposal lifecycle is unchanged.
|
||||
- Per-word authorship inside an accepted, re-pinned (plain) document.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit (pure render):** `trackChangesModel` renders each state to the expected
|
||||
author-colored markup — the four base states, both overlap directions, block
|
||||
add/remove, and resting/plain. Reuse the existing pure-render test pattern.
|
||||
- **Editor decorations:** the decoration plan produces the right per-author/op
|
||||
ranges, including the INV-32-reversal (committed changes now decorate) and
|
||||
stacked overlap ranges.
|
||||
- **Host E2E:** open a co-edited doc, assert both panes show author-colored
|
||||
marks; accept a proposal + re-pin → marks clear (pin→clean preserved).
|
||||
- Update/replace any test that asserts the **old** semantic green=add/red=remove
|
||||
classes or the INV-32 clean-editor behavior.
|
||||
+13
-10
@@ -31,13 +31,14 @@ const liveTurnOptions = {
|
||||
};
|
||||
|
||||
/** @type {import('esbuild').BuildOptions} */
|
||||
const previewOptions = {
|
||||
entryPoints: ["media/preview.ts"],
|
||||
outfile: "out/media/preview.js",
|
||||
const previewMermaidOptions = {
|
||||
entryPoints: ["media/preview-mermaid.ts"],
|
||||
outfile: "out/media/preview-mermaid.js",
|
||||
bundle: true,
|
||||
// The webview is a browser context; mermaid is bundled IN (and ONLY in) this
|
||||
// asset so it never bloats the extension-host bundle (the @cline/sdk size
|
||||
// discipline). No externals — everything is shipped to the sealed webview.
|
||||
// Task 7 (Q4): contributed via markdown.previewScripts into the BUILT-IN
|
||||
// preview's webview (a browser context) — mermaid is bundled IN (and ONLY
|
||||
// in) this asset so it never bloats the extension-host bundle (the
|
||||
// @cline/sdk size discipline).
|
||||
platform: "browser",
|
||||
format: "iife",
|
||||
target: "es2020",
|
||||
@@ -48,12 +49,14 @@ const previewOptions = {
|
||||
if (watch) {
|
||||
const ctx = await context(options);
|
||||
const ctxLive = await context(liveTurnOptions);
|
||||
const ctxPreview = await context(previewOptions);
|
||||
await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch()]);
|
||||
const ctxPreviewMermaid = await context(previewMermaidOptions);
|
||||
await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreviewMermaid.watch()]);
|
||||
console.log("esbuild: watching…");
|
||||
} else {
|
||||
await build(options);
|
||||
await build(liveTurnOptions);
|
||||
await build(previewOptions);
|
||||
console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js");
|
||||
await build(previewMermaidOptions);
|
||||
console.log(
|
||||
"esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview-mermaid.js",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Task 7 (D3/D21, PUC-3) — authorship + change annotations for the BUILT-IN VS
|
||||
* Code Markdown preview (`markdown.previewStyles`). Same F10 vocabulary as the
|
||||
* (sunsetting) custom webview's `media/preview.css`: style = operation
|
||||
* (underline = inserted, strikethrough = removed), color = author (human
|
||||
* green, Claude blue) — `cw-del` has no author variant (a single fixed
|
||||
* struck-red mark; see `previewAnnotations.ts`/`trackChangesModel.ts`'s "del"
|
||||
* sentinel tag). Light-theme defaults below; `body.vscode-dark` (also applied
|
||||
* on high-contrast dark) overrides with the same palette the webview preview
|
||||
* already ships, for continuity across both review surfaces.
|
||||
*/
|
||||
.cw-ins-claude {
|
||||
background: rgba(9, 105, 218, 0.12);
|
||||
border-bottom: 2px solid #0969da;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cw-ins-human {
|
||||
background: rgba(26, 127, 55, 0.12);
|
||||
border-bottom: 2px solid #1a7f37;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cw-del {
|
||||
background: rgba(207, 34, 46, 0.1);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #cf222e;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
body.vscode-dark .cw-ins-claude,
|
||||
body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-ins-claude {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
border-bottom-color: #58a6ff;
|
||||
}
|
||||
body.vscode-dark .cw-ins-human,
|
||||
body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-ins-human {
|
||||
background: rgba(63, 185, 80, 0.14);
|
||||
border-bottom-color: #3fb950;
|
||||
}
|
||||
body.vscode-dark .cw-del,
|
||||
body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-del {
|
||||
background: rgba(248, 81, 73, 0.11);
|
||||
text-decoration-color: #f85149;
|
||||
}
|
||||
|
||||
/*
|
||||
* F7.1 (#22) intra-diagram mermaid diff legend, shown beneath a mermaid fence
|
||||
* `previewAnnotations.ts` re-emitted through `mermaidDiff.ts` (Task 7 §2.6
|
||||
* parity — same legend markup + colors as the sunset webview's
|
||||
* `media/preview.css`).
|
||||
*/
|
||||
.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
|
||||
.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; }
|
||||
.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; }
|
||||
.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; }
|
||||
.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; }
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Task 7 (Q4 mermaid check, D3/D21): the built-in VS Code Markdown preview does
|
||||
* not render mermaid fences on its own — `previewAnnotations.ts`'s
|
||||
* `cowritingMarkdownItPlugin` teaches ```mermaid fences to render as
|
||||
* `<pre class="mermaid">SRC</pre>` (via `options.highlight`, the same
|
||||
* extension point the built-in preview's own fence rule already calls); this
|
||||
* script (contributed via `markdown.previewScripts`) is what actually turns
|
||||
* those into diagrams — mermaid needs a DOM, so it runs here, in the preview's
|
||||
* webview, never in the extension host. Bundled by esbuild as a standalone
|
||||
* IIFE → out/media/preview-mermaid.js, so mermaid never enters the host
|
||||
* bundle (matching the sealed webview's `media/preview.ts` precedent).
|
||||
*
|
||||
* Contributed preview scripts are reloaded on every content change (VS Code
|
||||
* docs), so running at module top-level is sufficient; the
|
||||
* `vscode.markdown.updateContent` listener is added defensively to also cover
|
||||
* in-place content updates that don't reload the script (mirrors the proven
|
||||
* `bierner.markdown-mermaid` extension's own approach).
|
||||
*
|
||||
* RESOLVED SCOPE NOTE (Q4 finding, migration plan Task 7 Step 2.6; wired in a
|
||||
* cross-task review follow-up): this script itself only turns `pre.mermaid`
|
||||
* nodes into diagrams — it never sees a diff. The intra-diagram diff/legend
|
||||
* augmentation (F7.1, INV-29..31) happens one step earlier, source-side,
|
||||
* where it CAN be unit-tested without a DOM: `previewAnnotations.ts`'s
|
||||
* `cowritingMarkdownItPlugin` re-emits a CHANGED mermaid fence's source
|
||||
* through `mermaidDiff.ts` (`buildMermaidQueue`, block-level `diffBlocks`
|
||||
* pairing against the F6/F7 baseline) before this script ever runs, so the
|
||||
* `pre.mermaid` node this script hands to `mermaid.run` already carries the
|
||||
* `classDef`/`class`/`linkStyle` styling directives — this script needn't (and
|
||||
* can't, DOM-less-ly) know a diagram changed at all. Basic mermaid rendering
|
||||
* (this file) is exercised by the same proven pattern as the real
|
||||
* `bierner.markdown-mermaid` extension; the augmentation upstream is exercised
|
||||
* by pure unit tests (`test/previewAnnotations.test.ts`).
|
||||
*/
|
||||
import mermaid from "mermaid";
|
||||
|
||||
function theme(): "dark" | "default" {
|
||||
return document.body.classList.contains("vscode-dark") || document.body.classList.contains("vscode-high-contrast")
|
||||
? "dark"
|
||||
: "default";
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const nodes = Array.from(document.querySelectorAll<HTMLElement>("pre.mermaid"));
|
||||
if (nodes.length === 0) return;
|
||||
mermaid.initialize({ startOnLoad: false, theme: theme(), securityLevel: "strict" });
|
||||
try {
|
||||
await mermaid.run({ nodes });
|
||||
} catch {
|
||||
// mermaid.run already marks failed nodes; ensure a visible chip per failure.
|
||||
for (const n of nodes) {
|
||||
if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("vscode.markdown.updateContent", () => void run());
|
||||
void run();
|
||||
@@ -1,98 +0,0 @@
|
||||
/* F7 track-changes preview — theme-aware via VS Code webview CSS variables. */
|
||||
body {
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editor-background);
|
||||
padding: 0 1.2rem 2rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#cw-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--vscode-editor-background);
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.85;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
#cw-summary .cw-add { color: var(--vscode-gitDecoration-addedResourceForeground); }
|
||||
#cw-summary .cw-del { color: var(--vscode-gitDecoration-deletedResourceForeground); }
|
||||
.cw-blk { position: relative; }
|
||||
ins, .cw-added {
|
||||
background: var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.15));
|
||||
text-decoration: none;
|
||||
}
|
||||
del, .cw-removed {
|
||||
background: var(--vscode-diffEditor-removedTextBackground, rgba(255, 0, 0, 0.15));
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.cw-changed { outline: 2px solid var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.25)); outline-offset: 2px; }
|
||||
.cw-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 0.7em;
|
||||
padding: 0 0.4em;
|
||||
border-radius: 3px;
|
||||
background: var(--vscode-badge-background);
|
||||
color: var(--vscode-badge-foreground);
|
||||
}
|
||||
.cw-error {
|
||||
border: 1px solid var(--vscode-inputValidation-errorBorder);
|
||||
background: var(--vscode-inputValidation-errorBackground);
|
||||
color: var(--vscode-errorForeground);
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
pre.mermaid { text-align: center; background: transparent; }
|
||||
pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
|
||||
|
||||
/* F9 authorship mode — inline author tints + block-level fence badges + toggle. */
|
||||
.cw-by-claude { background: var(--vscode-editorInfo-foreground, rgba(64, 120, 242, 0.18)); text-decoration: none; }
|
||||
.cw-by-human { background: var(--vscode-gitDecoration-addedResourceForeground, rgba(46, 160, 67, 0.18)); text-decoration: none; }
|
||||
.cw-blk.cw-by-claude, .cw-blk.cw-by-human, .cw-blk.cw-mixed { outline: 2px solid currentColor; outline-offset: 2px; background: transparent; }
|
||||
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
|
||||
#cw-summary .cw-prop { opacity: 0.85; }
|
||||
|
||||
/* F11 — preview toolbar buttons (Pin baseline; adaptive Ask Claude). Theme-aware. */
|
||||
#cw-header button {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
border: 1px solid var(--vscode-button-border, transparent);
|
||||
border-radius: 3px;
|
||||
padding: 0.1em 0.55em;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
#cw-header button:hover:not(:disabled) { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
#cw-header button:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* F10 interactive review — annotations toggle + ✓/✗ proposal blocks. */
|
||||
#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; }
|
||||
.cw-proposal {
|
||||
position: relative;
|
||||
border-left: 3px solid var(--vscode-charts-blue, #4daafc);
|
||||
background: color-mix(in srgb, var(--vscode-charts-blue, #4daafc) 12%, transparent);
|
||||
padding: 0.4em 0.6em; margin: 0.4em 0; border-radius: 3px;
|
||||
}
|
||||
.cw-proposal-unanchored { border-left-style: dashed; opacity: 0.85; }
|
||||
.cw-actions { position: absolute; top: 0.2em; right: 0.4em; display: inline-flex; gap: 0.25em; }
|
||||
.cw-actions button {
|
||||
cursor: pointer; border: 1px solid var(--vscode-button-border, transparent);
|
||||
border-radius: 3px; font-size: 0.9em; line-height: 1; padding: 0.1em 0.35em;
|
||||
background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
|
||||
.cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
|
||||
|
||||
/* F7.1 (#22) intra-diagram mermaid diff legend. */
|
||||
.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
|
||||
.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; }
|
||||
.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; }
|
||||
.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; }
|
||||
.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; }
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* F7 preview webview client (sealed sandbox, INV-21). Receives annotated HTML
|
||||
* from the extension host and swaps it in; runs mermaid over `.mermaid` blocks
|
||||
* (mermaid needs a DOM, so it runs here, not in the host). Bundled by esbuild as
|
||||
* a standalone IIFE → out/media/preview.js, so mermaid never enters the host
|
||||
* bundle. No network, no LLM.
|
||||
*/
|
||||
import mermaid from "mermaid";
|
||||
// Imported so esbuild emits the sibling out/media/preview.css (the controller
|
||||
// links it into the sealed shell via asWebviewUri).
|
||||
import "./preview.css";
|
||||
|
||||
declare function acquireVsCodeApi(): { postMessage(m: unknown): void };
|
||||
|
||||
interface RenderMessage {
|
||||
type: "render";
|
||||
mode: "on" | "off";
|
||||
html: string;
|
||||
epoch?: string;
|
||||
summary?: { added: number; removed: number; proposals: number };
|
||||
/** F11: false on a non-authorable doc → Pin + Ask-Claude controls disabled. */
|
||||
authorable?: boolean;
|
||||
}
|
||||
|
||||
const vscodeApi = acquireVsCodeApi();
|
||||
const body = document.getElementById("cw-body")!;
|
||||
const header = document.getElementById("cw-epoch")!;
|
||||
const summary = document.getElementById("cw-summary")!;
|
||||
const legend = document.getElementById("cw-legend")!;
|
||||
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
|
||||
const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null;
|
||||
const askEl = document.getElementById("cw-ask") as HTMLButtonElement | null;
|
||||
const acceptAllEl = document.getElementById("cw-acceptall") as HTMLButtonElement | null;
|
||||
|
||||
// F10: the annotations on/off toggle.
|
||||
annotationsEl?.addEventListener("change", () => {
|
||||
vscodeApi.postMessage({ type: "setMode", mode: annotationsEl.checked ? "on" : "off" });
|
||||
});
|
||||
|
||||
// F11 (SLICE-1): Pin baseline — post intent; the host pins via the F6 store (INV-35).
|
||||
pinEl?.addEventListener("click", () => {
|
||||
vscodeApi.postMessage({ type: "pinBaseline" });
|
||||
});
|
||||
|
||||
// F11 (SLICE-4): the single adaptive Ask-Claude button. Its label flips on
|
||||
// `selectionchange` (Edit Selection when live text is selected in the preview,
|
||||
// Edit Document otherwise), and a click resolves the selection to a SOURCE range
|
||||
// via the nearest `data-src` ancestors (INV-36) — the webview's sole mapping
|
||||
// duty. A selection that resolves to no live block falls back to document scope.
|
||||
|
||||
/** Walk up from a DOM node to the nearest block carrying data-src offsets (INV-36). */
|
||||
function nearestSrc(node: Node | null): HTMLElement | null {
|
||||
let el: HTMLElement | null = node instanceof HTMLElement ? node : (node?.parentElement ?? null);
|
||||
while (el && el !== body) {
|
||||
if (el.dataset.srcStart !== undefined && el.dataset.srcEnd !== undefined) return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The source [start,end) union of the live blocks a non-empty body selection touches, or null. */
|
||||
function selectionSrcRange(): { start: number; end: number } | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
|
||||
const ends = [nearestSrc(sel.anchorNode), nearestSrc(sel.focusNode)].filter(
|
||||
(e): e is HTMLElement => e !== null,
|
||||
);
|
||||
if (ends.length === 0) return null; // selection touches no live-source block
|
||||
const starts = ends.map((e) => Number(e.dataset.srcStart));
|
||||
const stops = ends.map((e) => Number(e.dataset.srcEnd));
|
||||
return { start: Math.min(...starts), end: Math.max(...stops) };
|
||||
}
|
||||
|
||||
function updateAskLabel(): void {
|
||||
if (!askEl) return;
|
||||
askEl.textContent = selectionSrcRange()
|
||||
? "✦ Ask Claude to Edit Selection"
|
||||
: "✦ Ask Claude to Edit Document";
|
||||
}
|
||||
|
||||
document.addEventListener("selectionchange", updateAskLabel);
|
||||
|
||||
askEl?.addEventListener("click", () => {
|
||||
const range = selectionSrcRange();
|
||||
if (range) {
|
||||
vscodeApi.postMessage({ type: "askClaude", scope: "selection", start: range.start, end: range.end });
|
||||
} else {
|
||||
vscodeApi.postMessage({ type: "askClaude", scope: "document" });
|
||||
}
|
||||
});
|
||||
|
||||
// #46 (INV-42): Accept all — batch-accept every pending proposal (intent only).
|
||||
acceptAllEl?.addEventListener("click", () => {
|
||||
vscodeApi.postMessage({ type: "acceptAll" });
|
||||
});
|
||||
|
||||
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
|
||||
body.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
|
||||
if (!btn) return;
|
||||
const block = btn.closest<HTMLElement>(".cw-proposal");
|
||||
const id = block?.dataset.proposalId;
|
||||
const action = btn.dataset.action;
|
||||
if (id && (action === "accept" || action === "reject")) {
|
||||
vscodeApi.postMessage({ type: action, proposalId: id });
|
||||
}
|
||||
});
|
||||
|
||||
function themeFor(): "dark" | "default" {
|
||||
return document.body.classList.contains("vscode-dark") ||
|
||||
document.body.classList.contains("vscode-high-contrast")
|
||||
? "dark"
|
||||
: "default";
|
||||
}
|
||||
|
||||
async function renderMermaid(): Promise<void> {
|
||||
const nodes = Array.from(body.querySelectorAll<HTMLElement>("pre.mermaid"));
|
||||
if (nodes.length === 0) return;
|
||||
mermaid.initialize({ startOnLoad: false, theme: themeFor(), securityLevel: "strict" });
|
||||
try {
|
||||
await mermaid.run({ nodes });
|
||||
} catch {
|
||||
// mermaid.run already marks failed nodes; ensure a visible chip per failure.
|
||||
for (const n of nodes) {
|
||||
if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
|
||||
const msg = event.data;
|
||||
if (msg?.type !== "render") return;
|
||||
body.innerHTML = msg.html;
|
||||
updateAskLabel(); // new content clears any selection → reset the adaptive label
|
||||
// F11 (PUC-1/7): disable edit controls on a non-authorable doc (reading stays on).
|
||||
const authorable = msg.authorable !== false;
|
||||
if (pinEl) pinEl.disabled = !authorable;
|
||||
if (askEl) askEl.disabled = !authorable;
|
||||
const on = msg.mode === "on";
|
||||
if (annotationsEl) annotationsEl.checked = on;
|
||||
// #46: Accept all shows only with ≥2 pending proposals, on an authorable doc,
|
||||
// in the annotated (on) state — a single proposal is just a ✓ in place.
|
||||
if (acceptAllEl) acceptAllEl.hidden = !on || !authorable || (msg.summary?.proposals ?? 0) < 2;
|
||||
// Off-state is a clean preview: hide the review chrome.
|
||||
header.hidden = !on;
|
||||
summary.hidden = !on;
|
||||
legend.hidden = true;
|
||||
if (on) {
|
||||
header.textContent = `Review since ${msg.epoch ?? ""}`;
|
||||
summary.innerHTML =
|
||||
`<span class="cw-add">+${msg.summary?.added ?? 0}</span> ` +
|
||||
`<span class="cw-del">−${msg.summary?.removed ?? 0}</span> ` +
|
||||
`<span class="cw-prop">${msg.summary?.proposals ?? 0} proposal${(msg.summary?.proposals ?? 0) === 1 ? "" : "s"}</span>`;
|
||||
}
|
||||
void renderMermaid();
|
||||
});
|
||||
+173
-46
@@ -18,6 +18,13 @@
|
||||
"onStartupFinished"
|
||||
],
|
||||
"contributes": {
|
||||
"markdown.markdownItPlugins": true,
|
||||
"markdown.previewStyles": [
|
||||
"./media/preview-annotations.css"
|
||||
],
|
||||
"markdown.previewScripts": [
|
||||
"./out/media/preview-mermaid.js"
|
||||
],
|
||||
"configuration": {
|
||||
"title": "Cowriting",
|
||||
"properties": {
|
||||
@@ -25,6 +32,11 @@
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "When Claude is editing, reveal the \"Cowriting: Claude\" output channel (without stealing focus) as soon as Claude starts producing text, so you can read the output as it streams."
|
||||
},
|
||||
"cowriting.annotations": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show authorship + change annotations in the Markdown preview for coedited documents."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -59,34 +71,36 @@
|
||||
"title": "Apply Agent Edit (internal seam)",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
"title": "Ask Claude to Edit",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editSelection",
|
||||
"title": "Ask Claude to Edit Selection",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.acceptProposal",
|
||||
"title": "✓ Accept Proposal",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.rejectProposal",
|
||||
"title": "✗ Reject Proposal",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.proposeAgentEdit",
|
||||
"title": "Propose Agent Edit (internal seam)",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.pinDiffBaseline",
|
||||
"title": "Cowriting: Pin Review Baseline to Now",
|
||||
"category": "Cowriting"
|
||||
"command": "cowriting.markReviewed",
|
||||
"title": "Mark Changes as Reviewed",
|
||||
"category": "Cowriting",
|
||||
"icon": "$(check-all)"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"title": "Open Cowriting Review Panel",
|
||||
"command": "cowriting.reviewChanges",
|
||||
"title": "Review Changes",
|
||||
"category": "Cowriting",
|
||||
"icon": "$(git-compare)"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.openReviewPreview",
|
||||
"title": "Open Cowriting Review Preview",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
@@ -98,6 +112,49 @@
|
||||
"command": "cowriting.acceptAllProposals",
|
||||
"title": "Accept All Claude Proposals",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.coeditDocument",
|
||||
"title": "✦ Coedit this Document with Claude",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.stopCoediting",
|
||||
"title": "Stop editing with Claude",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude",
|
||||
"title": "Ask Claude",
|
||||
"category": "Cowriting",
|
||||
"icon": "$(sparkle)"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.makeThreadEdit",
|
||||
"title": "✦ Make this edit",
|
||||
"category": "Cowriting",
|
||||
"icon": "$(sparkle)"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.toggleAnnotations",
|
||||
"title": "Toggle Annotations",
|
||||
"category": "Cowriting",
|
||||
"icon": "$(eye)"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -111,99 +168,168 @@
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.acceptProposal",
|
||||
"when": "false"
|
||||
"command": "cowriting.createThread",
|
||||
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.rejectProposal",
|
||||
"when": "false"
|
||||
"command": "cowriting.reviewChanges",
|
||||
"when": "editorLangId == markdown && cowriting.isCoediting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.pinDiffBaseline",
|
||||
"command": "cowriting.markReviewed",
|
||||
"when": "editorLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
"when": "editorLangId == markdown"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editSelection",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"when": "editorLangId == markdown"
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude",
|
||||
"when": "editorLangId == markdown && cowriting.isCoediting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.toggleAnnotations",
|
||||
"when": "editorLangId == markdown && cowriting.isCoediting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.makeThreadEdit",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.acceptAllProposals",
|
||||
"when": "editorLangId == markdown"
|
||||
"when": "editorLangId == markdown && cowriting.isCoediting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.rejectAllProposals",
|
||||
"when": "editorLangId == markdown && cowriting.isCoediting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.proposalAcceptMenu",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.proposalRejectMenu",
|
||||
"when": "false"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"when": "editorLangId == markdown",
|
||||
"command": "cowriting.reviewChanges",
|
||||
"when": "resourceLangId == markdown && cowriting.isCoediting",
|
||||
"group": "navigation@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude",
|
||||
"when": "resourceLangId == markdown && cowriting.isCoediting",
|
||||
"group": "navigation@2"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.toggleAnnotations",
|
||||
"when": "resourceLangId == markdown && cowriting.isCoediting",
|
||||
"group": "navigation@3"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.markReviewed",
|
||||
"when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot",
|
||||
"group": "navigation@4"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.openReviewPreview",
|
||||
"when": "resourceLangId == markdown",
|
||||
"group": "navigation@9"
|
||||
}
|
||||
],
|
||||
"editor/title/context": [
|
||||
{
|
||||
"command": "cowriting.editSelection",
|
||||
"when": "editorHasSelection && resourceLangId == markdown",
|
||||
"command": "cowriting.edit",
|
||||
"when": "resourceLangId == markdown && cowriting.isCoediting",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"when": "!editorHasSelection && resourceLangId == markdown",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"command": "cowriting.openReviewPreview",
|
||||
"when": "resourceLangId == markdown",
|
||||
"group": "1_cowriting@3"
|
||||
}
|
||||
],
|
||||
"explorer/context": [
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"command": "cowriting.openReviewPreview",
|
||||
"when": "resourceLangId == markdown",
|
||||
"group": "navigation@9"
|
||||
}
|
||||
],
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "cowriting.editSelection",
|
||||
"when": "editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"when": "!editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
||||
"command": "cowriting.edit",
|
||||
"when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.createThread",
|
||||
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)",
|
||||
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting",
|
||||
"group": "1_cowriting@2"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.coeditDocument",
|
||||
"when": "editorLangId == markdown && !cowriting.isCoediting",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.stopCoediting",
|
||||
"when": "editorLangId == markdown && cowriting.isCoediting",
|
||||
"group": "1_cowriting@9"
|
||||
}
|
||||
],
|
||||
"comments/commentThread/context": [
|
||||
{
|
||||
"command": "cowriting.reply",
|
||||
"group": "inline",
|
||||
"group": "inline@1",
|
||||
"when": "commentController == cowriting.threads"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.makeThreadEdit",
|
||||
"group": "inline@2",
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /\\boffer\\b/"
|
||||
}
|
||||
],
|
||||
"comments/commentThread/title": [
|
||||
{
|
||||
"command": "cowriting.makeThreadEdit",
|
||||
"group": "inline@1",
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /\\boffer\\b/"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.resolveThread",
|
||||
"group": "inline",
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /^open$/"
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /\\bopen\\b/"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.reopenThread",
|
||||
"group": "inline",
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /\\bresolved\\b/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"command": "cowriting.reviewChanges",
|
||||
"key": "ctrl+alt+r",
|
||||
"when": "editorLangId == markdown"
|
||||
"mac": "cmd+alt+r",
|
||||
"when": "editorLangId == markdown && cowriting.isCoediting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
"key": "ctrl+alt+e",
|
||||
"mac": "cmd+alt+e",
|
||||
"when": "editorTextFocus && editorLangId == markdown && cowriting.isCoediting"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -216,6 +342,7 @@
|
||||
"pretest:e2e": "npm run build && npm run clean:e2e && tsc -p tsconfig.e2e.json",
|
||||
"test:e2e": "node ./out/test/e2e/runTest.js",
|
||||
"smoke:live": "npm run build && node scripts/smoke-live-turn.mjs",
|
||||
"smoke:native": "npm run pretest:e2e && node scripts/smoke-native-loop.mjs",
|
||||
"vscode:prepublish": "node esbuild.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
// Task 9 / spec §7.1 rung 3: the manual, real-@cline/sdk smoke for the native
|
||||
// comment loop -- mirrors scripts/smoke-live-turn.mjs's role for F3 (a live
|
||||
// login-backed check, deliberately NOT in CI), but this one drives the whole
|
||||
// EDH (comment -> reply -> offer -> proposal needs vscode's own comment/doc
|
||||
// APIs, unlike smoke-live-turn.mjs's bare module call) via
|
||||
// test/e2e/smoke/nativeLoop.ts, launched exactly like test/e2e/runTest.ts
|
||||
// launches the regular host-E2E suites -- just against a single smoke entry
|
||||
// point that is NOT swept into `npm run test:e2e` (it lives outside
|
||||
// test/e2e/suite, which is the only directory suite/index.ts's mocha glob
|
||||
// searches).
|
||||
//
|
||||
// Prereqs: `npm run pretest:e2e` (builds the extension + compiles
|
||||
// test/e2e/**). `npm run smoke:native` does this for you.
|
||||
//
|
||||
// Opens the real sandbox/ folder (the repo's EDH workspace root), writes a
|
||||
// throwaway doc, posts a real comment via ThreadController.createThreadOnSelection,
|
||||
// waits for the real @cline/sdk reply + offer, makes + accepts the edit, then
|
||||
// prints the created proposal ids and deletes the throwaway doc.
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runTests } from "@vscode/test-electron";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function main() {
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const extensionDevelopmentPath = projectRoot;
|
||||
const extensionTestsPath = path.resolve(projectRoot, "out/test/e2e/smoke/nativeLoop");
|
||||
const sandbox = path.resolve(projectRoot, "sandbox");
|
||||
|
||||
if (!fs.existsSync(`${extensionTestsPath}.js`)) {
|
||||
console.error(
|
||||
`smoke-native-loop: ${extensionTestsPath}.js is missing -- run "npm run pretest:e2e" first (or "npm run smoke:native", which does this for you).`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Same macOS UNIX-socket-length workaround as test/e2e/runTest.ts (a long
|
||||
// worktree path + the default .vscode-test/user-data dir can blow past the
|
||||
// ~103-char limit).
|
||||
const userDataRoot = process.platform === "win32" ? os.tmpdir() : "/tmp";
|
||||
const userDataDir = fs.mkdtempSync(path.join(userDataRoot, "cwud-smoke-"));
|
||||
|
||||
try {
|
||||
await runTests({
|
||||
extensionDevelopmentPath,
|
||||
extensionTestsPath,
|
||||
launchArgs: [sandbox, "--disable-extensions", "--user-data-dir", userDataDir],
|
||||
});
|
||||
console.log("smoke-native-loop: PASS");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`smoke-native-loop: FAILED (expected when Claude Code is absent/signed out, or the sandbox/ folder is missing): ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,106 @@
|
||||
# Session 0056.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-26T04-24 (PST)
|
||||
> End: 2026-06-26T04-54 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-planning-and-executing implement #60 (live turn progress) from coauthoring-live-progress.md`
|
||||
|
||||
(Continuation of brainstorming session 0055, which graduated the spec; the
|
||||
operator said "implement it" → this coding session.)
|
||||
|
||||
## Plan
|
||||
|
||||
Plan + execute **#60** (P1 feature — live turn progress) from the graduated
|
||||
Solution Design `coauthoring-live-progress.md`. Four cuts: (1) pure
|
||||
`turnProgress.ts` reducer + tests; (2) `runEditTurn` + `onProgress`/`AbortSignal`;
|
||||
(3) `liveProgressUi` + setting + both call sites; (4) host-E2E + smoke.
|
||||
Branch `s60-live-progress` → PR → main.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Clean `main` at session start (fast-forwarded 13 commits — the sessions/ repo
|
||||
is the code repo itself). Baseline: 222 unit green.
|
||||
- #60 has a graduated Solution Design → §4.3 R2(b) satisfied.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. **Claim + plan.** Claimed 0056 (no in-flight). Wrote the implementation plan
|
||||
with `superpowers:writing-plans` → `docs/superpowers/plans/2026-06-26-live-turn-progress.md`
|
||||
(self-reviewed: full spec coverage, no placeholders). Grounded in the exact SDK
|
||||
types (`AgentToolCallPart.toolName`, `AgentUsage.inputTokens+outputTokens`), the
|
||||
vitest/host-E2E patterns, and the injectable `editTurn` seam. Branch
|
||||
`s60-live-progress`.
|
||||
2. **Task 1 — pure reducer.** `src/turnProgress.ts` + `test/turnProgress.test.ts`
|
||||
(13 cases). Fixed the type-only event import: `AgentRuntimeEvent` is exported
|
||||
from **`@cline/shared`**, not `@cline/sdk` (which doesn't re-export it) — caught
|
||||
by typecheck; amended the commit.
|
||||
3. **Task 2 — `runEditTurn`.** Added `RunEditTurnOptions {onProgress, signal}`;
|
||||
subscribes to the agent, folds events through the reducer, wires `signal` →
|
||||
`agent.abort()`; `finally` unsubscribes. Mocked-agent unit tests (vitest
|
||||
`doMock` intercepts the dynamic import).
|
||||
4. **Task 3 — host relay.** `src/liveProgressUi.ts` (notification line +
|
||||
"Cowriting: Claude" OutputChannel, reveal gated by setting) + `package.json`
|
||||
`contributes.configuration` `cowriting.liveProgress.revealOutput`.
|
||||
5. **Tasks 4+5 — both call sites.** `editSelection` (extension.ts) and preview
|
||||
`askClaude`/`runEditAndPropose` (trackChangesPreview.ts) made cancellable,
|
||||
relaying via the shared `liveProgressUi`; widened the `EditTurn` seam to accept
|
||||
`opts?` (back-compat). Added `liveProgressUi` to `CowritingApi`.
|
||||
6. **Task 6 — host E2E.** `test/e2e/suite/liveProgress.test.ts`: progress is
|
||||
additive (INV-44), aborted turn proposes nothing (INV-47).
|
||||
7. **Task 7 — verify + smoke.** typecheck + 237 unit + build green; enhanced
|
||||
`scripts/smoke-live-turn.mjs` to log progress.
|
||||
8. **Subagent review.** Found a **Medium** (a pre-aborted `AbortSignal` was a
|
||||
no-op — `agent.abort()` runs before the SDK's AbortController exists, so the
|
||||
turn ran to completion) + a **Low** (a throwing `onProgress` relay could fail
|
||||
the turn). Fixed both: short-circuit (throw) before `run()` on a pre-aborted
|
||||
signal; wrap the relay in try/catch. Strengthened the unit + E2E to prove it.
|
||||
9. **Ship.** Pushed; opened PR #61; operator approved merge despite the
|
||||
concurrency (below). Squash-merged `644885c`; issue #60 auto-closed.
|
||||
|
||||
## ⚠️ Concurrency event (mid-session)
|
||||
|
||||
Partway through, the shared working tree gained **uncommitted changes this session
|
||||
did NOT make** — a `cowriting.edit` / `routeEdit` reach refactor across
|
||||
`src/workspacePath.ts`, `test/workspacePath.test.ts`, and the menu/`routeEdit`
|
||||
parts of `package.json` + `src/extension.ts`. It broke 3 E2E (`f12Reach` ×2,
|
||||
`f11Toolbar` ×1) that still expect the old `editSelection`/`editDocument` command
|
||||
surface. Handling: committed **nothing** of it, discarded **nothing**; verified
|
||||
#60 green in isolation by stashing it (by path, reversible) — 237 unit + both #60
|
||||
E2E pass, only the documented `F10 #38` undo-sandbox flake remains — then popped
|
||||
the stash to restore it. Surfaced to the operator, who chose to merge #60 (its 7
|
||||
commits are isolated; the PR diff contains none of the foreign WIP).
|
||||
|
||||
## Cut state (at finalize)
|
||||
|
||||
- **#60 merged** to main (PR #61 squash `644885c`); issue #60 closed.
|
||||
- **Plan archived:** `submit-plan.sh` → content repo `plans/2026-06-26-live-turn-progress.md` (`22ec57e`).
|
||||
- **Foreign refactor WIP:** still uncommitted in the working tree, untouched —
|
||||
for whoever is doing it to continue/commit (it rebases onto the new main).
|
||||
- **Local tree:** left on branch `s60-live-progress` (merged) DELIBERATELY — a
|
||||
`checkout main`/`pull` would have disturbed the foreign uncommitted WIP. Local
|
||||
main not synced; origin/main has #60. Operator should reconcile the local tree
|
||||
+ the in-flight refactor.
|
||||
- **Memory:** added `session-0056-60-live-progress-shipped.md` + index line.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/wgl-planning-and-executing reconcile the local tree (sync main; land/branch the in-flight cowriting.edit/routeEdit refactor), then pick the next item (open: #59 P1 bug, #57, #58, #32, #35, #40, OQ-2)
|
||||
```
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Left local on `s60-live-progress` (did not sync main):** to avoid disturbing
|
||||
the concurrent uncommitted refactor in the shared tree. Low confidence this is
|
||||
the tidiest end state, but it is the safest for the foreign WIP. Operator to
|
||||
reconcile.
|
||||
- **Token field for the activity line:** used `inputTokens + outputTokens` from
|
||||
`AgentUsage` (no `totalTokens` field exists). Cache tokens excluded.
|
||||
- **OutputChannel append-not-clear + auto-reveal gating:** per spec §3.5; carried
|
||||
from session 0055's confirmed sub-decisions.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Session 0057.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-26T04-35 (PST)
|
||||
> End: 2026-06-26T06-55 (PST)
|
||||
> Type: executing-plans
|
||||
> Posture: yolo
|
||||
> Claude-Session: 3d66a467-8026-472d-9693-52a37939d493
|
||||
> Goal: Operator-feedback UX fixes on the Ask-Claude-to-Edit experience (broken
|
||||
> keybinding, missing/duplicated shortcuts, edit-box placement) — then iterate the
|
||||
> edit-input UX to its final form.
|
||||
> Outcome: Shipped two PRs to vscode-cowriting-plugin main (#62, #65); fixed and
|
||||
> shipped a dev-plugin bug (#134 / PR #135, v0.60.0) surfaced mid-session.
|
||||
|
||||
## Plan
|
||||
|
||||
Operator opened with three asks on the Ask-Claude UX: the live/review-panel
|
||||
shortcut didn't work, there was no shortcut for "Ask Claude to Edit
|
||||
Selection/Document" (wanted one combined shortcut), and the edit box appeared in
|
||||
a disliked spot (near the command palette) — with a request to recommend a
|
||||
VS-Code-idiomatic placement. Routed as an executing-plans (yolo) session.
|
||||
|
||||
The session became an extended live iterate-and-verify loop on the edit-input UX,
|
||||
driven by the operator testing each build in an Extension Development Host.
|
||||
|
||||
## Pre-session state
|
||||
|
||||
- On branch `s60-live-progress`; `#60` (live turn progress) work in flight.
|
||||
- A **concurrent session (0056)** was live in the SAME working checkout, actively
|
||||
committing/stashing `#60` — which clobbered this session's uncommitted edits
|
||||
twice before isolation.
|
||||
|
||||
## Turn-by-turn arc
|
||||
|
||||
1. **Phase A (keybindings + command unify).** Added one `cowriting.edit` command
|
||||
that routes selection-vs-document at runtime via a pure `routeEdit` helper
|
||||
(unit-tested); hid the two underlying commands from the palette; collapsed the
|
||||
split menus to one entry. Added `⌘⌥E`/`Ctrl+Alt+E` (edit) and `mac` variants
|
||||
for `⌘⌥R`/`Ctrl+Alt+R` (review panel) — the missing `mac` variant was why the
|
||||
panel shortcut "didn't work" (Option-key combos are unreliable on macOS).
|
||||
|
||||
2. **Concurrent-checkout incident + recovery.** Discovered session 0056 was
|
||||
clobbering the shared tree (git stash/pop/commit out from under this session).
|
||||
Exported the edits as a durable patch, created an **isolated git worktree**, and
|
||||
moved all work there. (§5.4 lesson — should have isolated up front.)
|
||||
|
||||
3. **Input-box placement (design Q).** Recommended + implemented an inline input
|
||||
via the Comments API; then iterated heavily per operator feedback:
|
||||
inline-at-selection → top-of-document → **finally a multi-line webview in a
|
||||
split pane below the document for BOTH scopes**. Each iteration was built and
|
||||
verified live in the EDH.
|
||||
|
||||
4. **Dev-plugin bug #134 (the root cause of the clobber).** Filed plugin feedback
|
||||
#134 (two sessions can share one working tree — isolation was prose, not a
|
||||
deterministic guard), then implemented the fix: `claim-session-id.sh` now stamps
|
||||
`> Checkout:` and **refuses a claim when another live session occupies the same
|
||||
checkout** (self-session excepted; `WGL_ALLOW_SHARED_CHECKOUT=1` override).
|
||||
Shipped as PR #135 / v0.60.0 and made live in the running install.
|
||||
|
||||
5. **Final edit-input form (PR #65).** Unified both scopes on the split-below
|
||||
multi-line webview (`editInstructionInput.ts` / `promptEditInstruction`):
|
||||
auto-focus, ⌘↵ send, Esc cancel-with-confirm-if-text, split collapses + focus
|
||||
restored to the doc on close (no Output/Debug panel popping), selection stays
|
||||
highlighted above. **Deleted the entire inline-comment mechanism** (`inlineAsk.ts`,
|
||||
`cowriting.askClaude.submit/cancel`, their menus, the Escape keybinding) — net
|
||||
deletion. Verified interactively, merged.
|
||||
|
||||
## Cut state (end of session)
|
||||
|
||||
| Repo | Change | Ref |
|
||||
| --- | --- | --- |
|
||||
| vscode-cowriting-plugin | Phase A: unify `cowriting.edit` + `routeEdit` + mac keybindings + inline Comments-API input | PR #62 → main (squash `9432300`) |
|
||||
| vscode-cowriting-plugin | Final: split-below multi-line webview for both scopes; remove inline-comment mechanism | PR #65 → main (squash `7e42d11`) |
|
||||
| wiggleverse-dev-claude-plugin | Deterministic shared-checkout guard in `claim-session-id.sh` + Step 6b docs; v0.60.0 | PR #135 → main (merge `ca383c4`); installed live |
|
||||
| wiggleverse-dev-claude-plugin (tracker) | Filed + closed feedback #134 (shared-checkout hazard) | issue #134 `resolution:done` |
|
||||
|
||||
- All worktrees (`s60-edit-ux`, `edit-top-anchor`, dev-plugin `issue134`) removed;
|
||||
branches deleted; all temporary EDH windows closed.
|
||||
- 242 unit + typecheck (src + e2e) + build green on the final tip. E2E electron
|
||||
suite not run (known env-broken locally); webview UX verified interactively.
|
||||
- Local `main` is 3 behind `origin/main` (sessions 0058/0059 + PR #65 merge) —
|
||||
cosmetic; a future session pulls.
|
||||
- Untracked strays `docs/superpowers/plans/2026-06-26-live-turn-progress.md` and
|
||||
`specs/` predate this session (from the `#60` line / a known `specs/` stray);
|
||||
left untouched — not this session's to land.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
None logged. No silent low-confidence calls — every judgment call (worktree
|
||||
recovery, each UX fork, cancel/placement/size trade-offs) was surfaced live and
|
||||
decided by the operator or flagged as a hard VS Code API limit.
|
||||
|
||||
## What lands on the operator's plate
|
||||
|
||||
- Nothing blocking. The edit-input UX line is complete and shipped.
|
||||
- The dev-plugin shared-checkout guard (#134/v0.60.0) is **live for new
|
||||
sessions** (this session's running install was updated; new sessions get it on
|
||||
start).
|
||||
- Known VS Code API limits captured in memory ([[ask-claude-edit-input-ux]]): no
|
||||
multi-line input at the command-palette location; no API to set an editor split
|
||||
ratio; no API to read bottom-panel visibility.
|
||||
|
||||
## Prompt the operator can paste into the next session
|
||||
|
||||
This session's thread (Ask-Claude edit UX) is **complete** — there is no single
|
||||
forced next step. Open backlog items the operator may choose from (each its own
|
||||
session): `#32` scroll-sync (feature, needs design), `#35` repo rename, `#40` undo
|
||||
provenance (P3), F11 spec graduation (OQ-2), content-repo draft reconciliation.
|
||||
|
||||
No `Next /goal:` is recorded — the next move is an operator pick from the backlog
|
||||
above.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Session 0058.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-26T05-12 (PST)
|
||||
> End: 2026-06-26T05-58 (PST)
|
||||
> Type: brainstorming
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
Operator opened by asking what was in the Gitea backlog, then directed a
|
||||
brainstorming session for a new feature: *"We should also show the proposed change
|
||||
in the Markdown file in the same way. The proposed change in the markdown file
|
||||
should be human-editable, show the diff just like it would after the change is
|
||||
completed in both the markdown file and the webview as the change is made. The
|
||||
diff in both the markdown file and preview webview should be exactly as they will
|
||||
be shown if the edit is accepted. Create a new working tree in a directory other
|
||||
than main for this work."* — extended mid-session with: *"replace the checkbox
|
||||
icon and 'x' icon on the proposed change as 'Accept' and 'Reject'. Each should
|
||||
have an arrow next to them for a dropdown that is 'Accept All' or 'Reject All'.
|
||||
One should be able to accept, reject, accept all, or reject all from either the
|
||||
markdown file or the web preview file."*
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `main`, clean/pushed baseline (after FF). Latest shipped work: #60 live
|
||||
turn progress (session 0056, PR #61). Session 0057 (a `cowriting.edit`/`routeEdit`
|
||||
refactor) still **in-flight** sharing the checkout.
|
||||
- Backlog at session start (8 open): #59 (P1 bug, Apple-Events prompt), #54/#57/#58
|
||||
(P2), #32/#35/#40 (P3), #62 (untriaged).
|
||||
|
||||
## Turn-by-turn arc
|
||||
|
||||
1. **Backlog query** — listed the 8 open Gitea issues for `benstull/vscode-cowriting-plugin`
|
||||
by priority; flagged #62 as the only untriaged item.
|
||||
2. **Session routing** — operator's feature request was feature-shaped *and*
|
||||
reversed a locked invariant (F10/INV-32 "clean editor"), so the session type
|
||||
was ambiguous. Asked; operator chose **brainstorm a spec first**. Routed via
|
||||
`wgl-brainstorming`; claimed session **0058**.
|
||||
3. **Isolation** — session 0057 still in-flight on the shared checkout → created an
|
||||
isolated worktree `vscode-cowriting-plugin-s58` on branch
|
||||
`s58-inline-editor-diff` off `origin/main` (§5.4).
|
||||
4. **Exploration** — dispatched an Explore agent that mapped five areas: webview
|
||||
render engine (`trackChangesModel.ts`), F4 proposal model + seam, the F10
|
||||
clean-editor decision (zero editor decorations remain), controllers/wiring, and
|
||||
commands/toolbar. Key constraint surfaced: **VS Code text editors cannot host
|
||||
editable phantom text** — anything editable in the editor is buffer content;
|
||||
decorations only style existing text or inject *non-editable* before/after
|
||||
content.
|
||||
5. **Four design forks (AskUserQuestion, with ASCII previews):**
|
||||
- Editor model → **optimistic apply + decorations**.
|
||||
- Timing → **on proposal** (not live token-stream into the editor).
|
||||
- Editor affordance → **CodeLens `Accept ▾ / Reject ▾` + QuickPick dropdown**.
|
||||
- (Operator-added) Control parity → Accept/Reject/Accept-all/Reject-all from
|
||||
both surfaces; ✓/✗ superseded.
|
||||
6. **Design presented** section-by-section; operator approved with "keep going"
|
||||
(nodded the two flagged sub-decisions: dedicated `EditorProposalController`;
|
||||
allow save of pending text).
|
||||
7. **Anchor + spec** — filed Feature **#64** (`type/feature`, `priority/P2`) since
|
||||
no tracker anchor existed; wrote `specs/coauthoring-inline-editor-diff.md`
|
||||
(INV-48..54, reversing INV-10/INV-32). Self-review clean. Operator **approved**.
|
||||
|
||||
## Cut state
|
||||
|
||||
- Spec **graduated** → content repo `vscode-cowriting-plugin-content` at `80d5d1a`
|
||||
(`specs/coauthoring-inline-editor-diff.md`).
|
||||
- Feature **#64** filed + labelled.
|
||||
- Worktree `vscode-cowriting-plugin-s58` (branch `s58-inline-editor-diff`) stands
|
||||
ready for the downstream planning-and-executing session; no code committed this
|
||||
session (brainstorming output is the spec only).
|
||||
- Memory updated: `f12-inline-editor-diff-spec-graduated.md` + MEMORY.md index.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
No autonomous low-confidence calls — every fork was decided live with the operator
|
||||
via AskUserQuestion, and the spec was operator-approved before submission. (The
|
||||
two design sub-decisions — dedicated controller, allow-save-of-pending — were
|
||||
flagged in the presented design and nodded by the operator.)
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/goal Plan-and-execute #64 (F12 inline editable proposed-change diff in the Markdown editor) from specs/coauthoring-inline-editor-diff.md, in worktree vscode-cowriting-plugin-s58 (branch s58-inline-editor-diff); follow the spec's 5-slice Delivery Plan
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
# Session 0059.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-26T06-01 (PST)
|
||||
> End: 2026-06-26T08-32 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
Plan-and-execute #64 (F12 inline editable proposed-change diff in the Markdown
|
||||
editor) from the graduated spec `specs/coauthoring-inline-editor-diff.md`, in
|
||||
isolated worktree `vscode-cowriting-plugin-s58` (branch `s58-inline-editor-diff`),
|
||||
opened straight off brainstorming session 0058.
|
||||
|
||||
## Plan
|
||||
|
||||
Implement #64 per spec: optimistic-apply the proposed change into the editor
|
||||
buffer (editable, tinted insertions + struck-red deletion hints), accept =
|
||||
finalize-in-place / reject = revert-in-place, `Accept ▾`/`Reject ▾` (+ Accept-all
|
||||
/Reject-all) from both editor (CodeLens+QuickPick) and webview. Reverses
|
||||
INV-10/INV-32; INV-48..54. Implementation plan:
|
||||
`docs/superpowers/plans/2026-06-26-inline-editor-diff.md`.
|
||||
|
||||
## Outcome — SHIPPED
|
||||
|
||||
#64 (F12) **shipped to main** — PR #66 squash `7b98249`, issue auto-closed.
|
||||
Worktree built, subagent-driven (fresh implementer per task + two-stage review),
|
||||
adversarial final review, then merged through the concurrent #65 landing.
|
||||
|
||||
## Turn-by-turn arc
|
||||
|
||||
1. **Init** — claimed session 0059 (0057 still in-flight; isolated worktree made
|
||||
it §5.4-safe). Synced worktree, `npm ci`, green baseline (242 unit).
|
||||
2. **Plan** — read the whole subsystem (proposalController, trackChangesModel,
|
||||
attributionController, trackChangesPreview, preview.ts/css, extension.ts,
|
||||
package.json, E2E harness); wrote an 8-task TDD plan. Surfaced a **spec
|
||||
refinement**: optimistic apply must re-anchor the proposal to the applied text
|
||||
+ store `Proposal.original` (F4's `fp.text` is the original, gone once applied).
|
||||
3. **Execute (subagent-driven, sonnet implementers):**
|
||||
- T1 `Proposal.original` (model) — `bea9fd5`.
|
||||
- T2 `setProposalApplied` re-anchor helper — `92ff4dd`.
|
||||
- T3 seam `landBaseline` opt + `signalLanded` — `6d54963`. **Surfaced the
|
||||
host-E2E was blocked by a macOS UNIX-socket-length `EINVAL`** (the long
|
||||
worktree path). Fixed by pinning `--user-data-dir` to a short `/tmp` path
|
||||
(`930b4ab`) → host-E2E now launches from a worktree.
|
||||
- T4 `optimisticApply`/`finalizeInPlace`/`revertInPlace`/`rejectAll` —
|
||||
`ddbbb7a` (subagent caught a real `keyOf` vs `uri.toString()` test bug).
|
||||
- T5 `EditorProposalController` + pure `decorationPlan` — `8c6e782`+`e599284`
|
||||
(subagent added a setTimeout(0) apply-debounce for the batch race; updated 5
|
||||
existing E2E for the INV-10 reversal — verified legitimate, not weakened).
|
||||
- T6 render-once INV-50 reconciliation — `6529332`; I added a coloring-
|
||||
alignment fix (`landedSpans` via `toLanded`) the subagent's note flagged —
|
||||
`3805323`.
|
||||
- T7 webview `Accept ▾`/`Reject ▾` + dropdown + `rejectAllProposals` — `f4594da`.
|
||||
4. **Adversarial final review (opus subagent)** — found a **CRITICAL** (revert
|
||||
target clobbered on save+reload, in-memory `applied` empty post-reload) and a
|
||||
MINOR (toolbar summary double-counts). Both fixed + a reload-safety regression
|
||||
E2E — `6560894`. Verdict invariants INV-48..54 upheld; decorationPlan alignment
|
||||
verified correct.
|
||||
5. **Concurrency** — session 0057 had merged **#65** (unify Ask-Claude on a
|
||||
split-below webview; removed `InlineAskController`) to main mid-session. Merged
|
||||
origin/main into the branch, resolved the one `extension.ts` conflict (drop
|
||||
inlineAsk, keep `EditorProposalController`); suite green; squash-merged PR #66.
|
||||
6. **Hygiene** — a `git add -A` during the merge accidentally tracked the spec
|
||||
copy in the code repo; removed it (PR #67 `844a278`) — specs are canonical in
|
||||
the content repo. Plan archived (`8c14f54`); spec patched to **v0.1.1** with the
|
||||
re-anchor refinement and submitted (`43913dc`).
|
||||
|
||||
## Cut state
|
||||
|
||||
- **main @ `844a278`** (clean, pushed). #64 closed.
|
||||
- Tests: **249 unit + 87/5 host-E2E green**, typecheck clean.
|
||||
- Content repo: spec v0.1.1 (`43913dc`) + plan (`8c14f54`).
|
||||
- Worktree removed.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Re-anchor on optimistic apply (spec refinement, RESOLVED into v0.1.1).** Added
|
||||
`Proposal.original` + re-fingerprint to the applied text; captured once →
|
||||
reload-safe. Folded into the spec.
|
||||
- **`finalizeInPlace` has no orphan re-resolve guard** (CodeLens Accept), unlike
|
||||
the webview `acceptById`. Intentional — spec §2.2 says Accept keeps the human's
|
||||
in-place edits (which would orphan the exact-text fp) — but it's asymmetric. Left
|
||||
as-is; noted as a follow-up nit.
|
||||
- **Per-proposal webview carets always show Accept-all/Reject-all** even at 1
|
||||
pending (the toolbar button correctly hides <2). Cosmetic; not fixed.
|
||||
|
||||
## Not done
|
||||
|
||||
- **Manual smoke (interactive F5)** — the live editor diff, CodeLens dropdown,
|
||||
both-surface parity, and save-while-pending need an Extension Development Host
|
||||
run (operator-side). No flotilla/PPE for this app — the PR-merge to main is the
|
||||
ship.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/goal Manually smoke-test #64 (F12 inline editor diff) in an Extension Development Host (F5) — verify the live editable diff + deletion hints, the Accept ▾/Reject ▾ CodeLens dropdown, editor↔webview parity, and save-while-pending — then start the next backlog item; #59 (P1, macOS Apple-Events prompt, likely @cline/sdk) is highest priority
|
||||
```
|
||||
@@ -0,0 +1,124 @@
|
||||
# Session 0060.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-26T12-52 (PST)
|
||||
> End: 2026-06-26T13-14 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/goal next` — resume the stored next-goal from session 0059's finalize:
|
||||
"Manually smoke-test #64 in an Extension Development Host (F5), then pick the
|
||||
next backlog item — #59 (P1 Apple-Events prompt) is highest priority."
|
||||
|
||||
The #64 F5 smoke is an interactive operator gesture (flagged, not performed by
|
||||
the session). The autonomous substantive work was **fixing #59** — a standalone
|
||||
leaf `type/bug` (P1), eligible for plan-and-execute under §4.3 R2(b) with no
|
||||
design required.
|
||||
|
||||
## Plan
|
||||
|
||||
Fix #59: the plugin triggers a spurious macOS "control other applications"
|
||||
(Apple Events / Automation TCC) permission prompt; works fine when declined.
|
||||
Root-cause via systematic-debugging, fix via TDD, ship through branch → PR →
|
||||
merge to main.
|
||||
|
||||
## Turn-by-turn arc
|
||||
|
||||
1. **Session init.** Classified `/goal next` → planning-and-executing; claimed
|
||||
session 0060 (`claim-session-id.sh`, no other sessions in flight). Baseline:
|
||||
pulled `main` (was 1 behind), clean apart from two pre-existing stray
|
||||
untracked files (`specs/coauthoring-live-progress.md`,
|
||||
`docs/superpowers/plans/2026-06-26-live-turn-progress.md` — canonical copies
|
||||
live in the content repo; left untouched).
|
||||
|
||||
2. **Read #59** (Gitea API). P1, type/bug. Lead in the issue: not the extension's
|
||||
own code; probable origin `@cline/sdk` agent activation / Claude-Code
|
||||
hub-discovery, which falls back gracefully when denied.
|
||||
|
||||
3. **Root-cause investigation (systematic-debugging Phase 1–2).**
|
||||
- The extension's own code and the **entire JS dependency tree** contain no
|
||||
osascript/AppleScript (only the build-only `vite`). The Apple Event is sent
|
||||
by a spawned subprocess.
|
||||
- Traced the spawn chain: `src/liveTurn.ts` `new sdk.Agent({providerId:
|
||||
"claude-code"})` → `@cline/sdk` → `@cline/llms` `createClaudeCodeProviderModule`
|
||||
→ `ai-sdk-provider-claude-code` `createClaudeCode(options)` → `@anthropic-ai/
|
||||
claude-agent-sdk` `query()` → **spawns the bundled `claude` CLI**
|
||||
(`@anthropic-ai/claude-agent-sdk-darwin-arm64/claude`) headless
|
||||
(`--print --output-format stream-json`).
|
||||
- `runEditTurn` (liveTurn.ts:63) is the **sole** agent-spawn site (`cline.ts`
|
||||
only reads the static tool catalog).
|
||||
- Binary forensics: the `claude` binary contains **26 osascript calls** incl.
|
||||
`tell application "Terminal"/"iTerm"/"Electron"/"Finder"/"System Events"`.
|
||||
Categorized all 26 — every real call is **user-action-gated** (clipboard
|
||||
image, screenshot, `Apple_Terminal`-only theme read, notifications,
|
||||
terminal-setup, Claude-Desktop deep link via `tell application "Electron"`,
|
||||
browser-default-handler detection). The only path that fires **without user
|
||||
action** when `claude` is launched inside the VS Code extension host is
|
||||
**IDE auto-connect** (osascripts to identify/activate the editor). We never
|
||||
use the IDE connection → declining is harmless = exact symptom.
|
||||
- Gate confirmed: `if(!((autoConnectIde||…||CLAUDE_CODE_SSE_PORT||…||
|
||||
CH(CLAUDE_CODE_AUTO_CONNECT_IDE)) && !mK(CLAUDE_CODE_AUTO_CONNECT_IDE))) return;`
|
||||
with `mK("0")===true` → `CLAUDE_CODE_AUTO_CONNECT_IDE=0` hard-disables it.
|
||||
|
||||
4. **Fix design (verified end-to-end, statically).** Pass `options.env` to the
|
||||
Agent. The provider merges `{...getBaseProcessEnv(), ...settings.env}` and the
|
||||
agent-SDK spawns the child with `env = L4 ? {...L4} : {...process.env}`. Seeding
|
||||
`options.env` with the full `process.env` keeps default inheritance intact
|
||||
(login under HOME, proxy/CA vars) and adds only `CLAUDE_CODE_AUTO_CONNECT_IDE=0`
|
||||
(+ `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1`, belt-and-suspenders for the
|
||||
extension-install deep link). `options` type is `Record<string,unknown>` — no
|
||||
cast.
|
||||
|
||||
5. **TDD.** RED: unit test asserts `runEditTurn` constructs the Agent with both
|
||||
switches set and the rest of `process.env` preserved (fake Agent captures
|
||||
`cfg`). Watched it fail (`env` undefined). GREEN: added `options.env` to the
|
||||
Agent constructor in `liveTurn.ts`. 250 unit + typecheck + build green.
|
||||
|
||||
6. **Empirical live-turn smoke (`scripts/smoke-live-turn.mjs` + osascript shim)
|
||||
was DECLINED** by the operator (it makes a live network/auth'd agent call).
|
||||
Env-preservation rests on the static chain proof + the unit test instead.
|
||||
|
||||
7. **Ship.** Branch `s60-59-apple-events-prompt` → commit → push → **PR #68** →
|
||||
squash-merge to main (`e671c4b`), branch deleted, **#59 auto-closed**.
|
||||
Post-merge: 250 unit green on fresh main.
|
||||
|
||||
## Cut state
|
||||
|
||||
- **#59 SHIPPED** to main (squash `e671c4b`, PR #68; issue closed).
|
||||
- `src/liveTurn.ts` (+19) and `test/liveTurn.test.ts` (+38) only.
|
||||
- 250 unit + typecheck + build green; E2E unaffected (it stubs the turn, never
|
||||
reaching the real spawn path).
|
||||
- No implementation-plan artifact (small leaf bug) → nothing to archive in
|
||||
`plans/`. No deploy pipeline stage (VS Code extension, no flotilla/PPE) →
|
||||
change ships at merge.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Did not run the real-turn empirical smoke** (operator declined the live
|
||||
agent call). Alternative: run it with an osascript shim to observe the trigger
|
||||
directly. Why proceeded: the env→spawn chain and gate semantics are proven by
|
||||
static analysis, the plumbing is unit-tested, and the definitive confirmation
|
||||
(fresh-TCC GUI check) is inherently an operator gesture per the issue's
|
||||
acceptance — so the smoke was not load-bearing for shipping.
|
||||
- **Set both `AUTO_CONNECT_IDE=0` and `IDE_SKIP_AUTO_INSTALL=1`** rather than the
|
||||
single minimal switch. Alternative: auto-connect=0 alone (likely sufficient,
|
||||
since auto-install rides the auto-connect flow). Why both: cheap, explicit,
|
||||
both are no-ops outside an IDE; defense-in-depth against a separately-gated
|
||||
install path.
|
||||
|
||||
## Operator plate (next session)
|
||||
|
||||
1. **Operator F5 smoke of #64** (F12 inline editor diff) — still pending from 0059.
|
||||
2. **Fresh-TCC GUI smoke of #59** — reset macOS Automation TCC for the host app,
|
||||
exercise a Claude edit, confirm the "control other applications" prompt is
|
||||
gone and the edit still works.
|
||||
3. Then a backlog item — **#58** (P2 story, self-contained) is the most actionable.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/goal Operator-smoke #64 (F5) + fresh-TCC GUI smoke of #59, then plan-and-execute #58 (proposal accept/decline word-level diff — route proposalBlockHtml through wordMergedMarkdown)
|
||||
```
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
# Session 0056.0 — Transcript
|
||||
# Session 0061.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-26T04-24 (PST)
|
||||
> Start: 2026-06-27T05-14 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0056 for vscode-cowriting-plugin. The driver replaces this
|
||||
> This file reserves session ID 0061 for vscode-cowriting-plugin. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0056.0-TRANSCRIPT-2026-06-26T04-24--<end>.md form at session end.
|
||||
> SESSION-0061.0-TRANSCRIPT-2026-06-27T05-14--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Session 0062.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-27T17-02 (PST)
|
||||
> Type: brainstorming
|
||||
> Posture: careful
|
||||
> Claude-Session: 2c832f9a-a70c-4d71-ab13-c4f39d6eb595
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0062 for vscode-cowriting-plugin. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0062.0-TRANSCRIPT-2026-06-27T17-02--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
_(launch prompt not captured at claim time)_
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Appended as the session runs; surfaced at
|
||||
finalize. Empty if none._
|
||||
@@ -0,0 +1,65 @@
|
||||
# Session 0063.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-07-01T18-49 (PST)
|
||||
> Type: brainstorming
|
||||
> Posture: careful
|
||||
> Claude-Session: 1a8fc96b-6f36-4102-be7b-decb53251b2b
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-s0063
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0063 for vscode-cowriting-plugin. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0063.0-TRANSCRIPT-2026-07-01T18-49--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Examine the existing vs plugin, the prototype, and the half-baked spec that I used Opus to create and make a recommendation for a VS Code experience that allows humans and machines to collaborate on a document, with the machine making suggestions based on human input and the human able to approve, reject, or modify those suggestions. This should use as many VS Code UX paradigms as possible, use as many existing VS Code components or extensions, but create custom components/UX when needed. You can assume this is just Claude as the 'machine' to start
|
||||
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
> Anchor: design-only north-star recommendation (no tracker anchor; lineage =
|
||||
> Epic #1 / F1–F12 + session 0062's `coauthoring-native-surfaces.md` draft —
|
||||
> same anchor treatment 0062 recorded)
|
||||
|
||||
1. Examine the three inputs: the shipped plugin (F1–F12 code on `main`), the
|
||||
prototype, and the half-baked Opus-authored spec (untracked `specs/` in the
|
||||
primary checkout).
|
||||
2. Identify/confirm the anchoring issue (§4.3 R1 gate) — capture one if none fits.
|
||||
3. Walk the consequential UX decisions with the operator (AskUserQuestion
|
||||
checkpoints), grounded in native VS Code paradigms vs custom components.
|
||||
4. Draft the recommendation as a Solution-Design spec in the content repo's
|
||||
`specs/`; whole-document review at the Gitea render URL.
|
||||
|
||||
## Session record (running)
|
||||
|
||||
- Examined the three inputs: shipped plugin (F1–F12 inventory), prototype
|
||||
(`vscode-cowriting-prototype` = rung-2 spike: E1/E3/E5 PASS — key finding:
|
||||
CodeLens does not render in a diff editor; E2/E4 unbuilt), and the 0062 draft
|
||||
`coauthoring-native-surfaces.md` v0.1.0 + companion spike plan.
|
||||
- Recommendation delivered + four forks decided with the operator
|
||||
(AskUserQuestion checkpoints, all recommended options chosen):
|
||||
1. Review model = **in-buffer pending changes + per-hunk CodeLens** (D18;
|
||||
supersedes multi-diff Keep/Undo; amends INV-5/INV-12; resolves Q2/Q7).
|
||||
2. Packaging (Q1) = **evolve the shipped extension in place** (D17; §6.10
|
||||
rewritten from "greenfield").
|
||||
3. Ask surface = **comments-first** (D19; split-pane input webview sunsets).
|
||||
4. Artifact = **amend the 0062 spec to v0.2.0** on content-repo branch
|
||||
`session-0063` (0062's working tree untouched). Also D20 (tweaks × per-hunk
|
||||
decisions), D21 (D11 → shipped reality).
|
||||
- Spec v0.2.0 + spike-spec status committed (`431ebeb`) and pushed to
|
||||
`session-0063`; whole-document review handed to the operator at the Gitea
|
||||
render URL (terminal gate).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- Proceeded past the concurrent-session warning (0061/0062 both INPROGRESS,
|
||||
placeholder-only claims from 2026-06-27) because the operator explicitly
|
||||
launched this session; isolated in worktree `session-0063` per #134/§5.4.
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Appended as the session runs; surfaced at
|
||||
finalize. Empty if none._
|
||||
@@ -0,0 +1,58 @@
|
||||
# Session 0064.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-07-01T22-19 (PST) · End: 2026-07-02T05-34 (PST)
|
||||
> Type: writing-plans
|
||||
> Posture: careful
|
||||
> Claude-Session: 7c03a44e-1f92-4445-afd9-928efd55afba
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-wp (isolated worktree; torn down at finalize)
|
||||
> Anchor: design specs/coauthoring-native-surfaces.md (v0.2.1, graduated)
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> Got it. Let's implement the full plugin
|
||||
> (operator, after driving the rung-2 feasibility spike; context: evolve the shipped
|
||||
> extension in place onto specs/coauthoring-native-surfaces.md, D17/§6.10/§7.1)
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Native-surfaces Solution Design at **v0.2.0, draft**, on content-repo branch `session-0063` (session 0063 INPROGRESS, awaiting operator review). Content `main` had never received the 0062/0063 spec lineage.
|
||||
- Rung-2 feasibility spike: E1/E3/E5 PASS (recorded in v0.2.0); **E2/E4 unvalidated**. Earlier in this same conversation (pre-session), the throwaway prototype `vscode-cowriting-prototype` was rebuilt to drive the FULL v0.2.0 loop (enter → ask-via-comment → pending-in-buffer → tweak → baseline/diff → preview → stop) and the operator drove it end to end.
|
||||
- Three concurrent INPROGRESS sessions (0061/0062/0063); 0062 occupied the main plugin checkout → this session claimed from an isolated worktree (#134 gate).
|
||||
|
||||
## Arc (turn by turn, condensed)
|
||||
|
||||
1. **Spike driving (pre-claim, same conversation):** operator drove the walkthrough; two E4 defects surfaced and were fixed live in the prototype — (a) "Make this edit" gated on a nonexistent `commentThreadContextValue` key (real key: **`commentThread`**); (b) Ask-Claude didn't focus the comment input (fixed via **`workbench.action.addComment`**); (c) "cursor must be within a commenting range" → commenting ranges are **cached per document**; re-assigning `commentingRangeProvider` is the only refresh signal. All recorded in the prototype's FINDINGS.md.
|
||||
2. **Gate + claim:** operator asked to "implement the full plugin" → `wgl-writing-plans` init; claim refused the shared checkout (live 0062) → isolated worktree `vscode-cowriting-plugin-wp`, claimed **0064** (`--type writing-plans`, careful).
|
||||
3. **Eligibility:** `check-issue-eligibility.sh --for implementation-plan --design …coauthoring-native-surfaces.md` → **ELIGIBLE (R2a)**. Design-readiness: N/A (spec §5.1 = Code directly, no UI design).
|
||||
4. **Sequencing decision (operator):** fold spike findings first, then plan. Operator confirmed the drive reached step 5+ → **E2 PASS**; E4 **PASS-with-fallback**.
|
||||
5. **Spec v0.2.1 delta** (content worktree branch `session-0064` off `session-0063`): §6.4 spike-verified API seams (commenting-range refresh, addComment focus, `commentThread` when-key, offer affordances; baseline nested-repo + `state.HEAD.commit` notes), §7.1 rung 2 closed (E2 PASS / E4 PASS-with-fallback; D15 preview-selection half explicitly un-spiked), §7.3 risks updated. Pushed; operator **approved → graduated**.
|
||||
6. **Plan written** (`superpowers:writing-plans`; codebase mapped via an Explore subagent + direct reads): `docs/superpowers/plans/2026-07-01-native-surfaces-migration.md` — ONE plan, 9 tasks in §7.1 rung-4 order: (1) CoeditingRegistry (INV-10) → (2) baseline router git-HEAD+snapshot (INV-7; **retires INV-18** machine-landing advance) → (3) QuickDiff + `cowriting-baseline:` + Review Changes + status bar (INV-13) → (4) gate every surface → (5) extract EditFlow from the review webview → (6) comments-first ask + comment→reply→offer→proposal loop (D19/D10/D8; sunsets the input webview) → (7) built-in-preview annotations via markdown-it sentinels (D3/D21; Q4 mermaid stop-and-record check) → (8) sunset the review webview + ✓ Keep/✗ Reject copy + README → (9) full-loop E2E + real-SDK smoke (rung 3 folded in). D15 explicitly deferred. Committed to plugin branch `session-0064`, pushed.
|
||||
7. **Review gate:** operator approved — explicitly accepting the **D15 deferral** and the **INV-18 retirement** — and directed execution to a new session.
|
||||
8. **Finalize (single approved plan):** content `session-0064` merged → content `main` (landed the whole 0062/0063/0064 spec lineage; flagged), plugin `session-0064` fast-forwarded → plugin `main`; plan archived via `submit-plan.sh` → content `plans/2026-07-01-native-surfaces-migration.md` (ddaf3b0); **issue #69** filed (D15, feature P2, resume context in body); both session worktrees removed; memory updated.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — the two judgment calls (D15 deferral, INV-18 retirement) were put to the operator explicitly and approved at the plan gate._
|
||||
|
||||
## Cut state
|
||||
|
||||
- **Spec:** `coauthoring-native-surfaces.md` **v0.2.1 `graduated`**, on content `main`.
|
||||
- **Plan:** approved + archived at content `plans/2026-07-01-native-surfaces-migration.md`; also on plugin `main` under `docs/superpowers/plans/`. **Not executed** (by design — writing-plans is terminal).
|
||||
- **Spike:** complete (5/5); prototype **retained deliberately** (operator: iterate UX there before the real build) — supersedes the spike-plan's delete-when-done for now.
|
||||
- **Tracker:** #69 (D15 preview-initiated comments, feature P2) parked with full resume context.
|
||||
- Leftover: merged-but-undeleted local branch refs (`session-wp-native-surfaces` in the plugin clone, `session-0064` in the content clone) — operator declined force-deletion; plain `-d` from a main checkout clears them. Sessions 0061/0062/0063 remain INPROGRESS (0062/0063 will find their content branches already merged).
|
||||
|
||||
## Operator plate
|
||||
|
||||
- Open the executing session (below) when ready.
|
||||
- Sessions 0061/0062/0063 are still unfinalized — worth closing out.
|
||||
- Optional tidy: delete the two stale local branch refs.
|
||||
|
||||
## Next session
|
||||
|
||||
```
|
||||
/goal Execute the native-surfaces migration plan (content repo plans/2026-07-01-native-surfaces-migration.md, 9 tasks, from graduated spec coauthoring-native-surfaces.md v0.2.1) end to end
|
||||
```
|
||||
|
||||
(Open with `wgl-executing-plans`; the plan's tasks run 1→9 strictly, each ending typecheck+unit+host-E2E green; Task 9's live smoke is the operator-run rung-3 gate.)
|
||||
@@ -0,0 +1,68 @@
|
||||
# Session 0065.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-07-02T06-31 (PST)
|
||||
> End: 2026-07-02T16-10 (PST)
|
||||
> Type: executing-plans
|
||||
> Posture: yolo
|
||||
> Claude-Session: 32dcda47-7361-4fa7-8145-9d118b5de3cc
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin/.claude/worktrees/session-native-surfaces-exec (isolated worktree, branch `session-0065`)
|
||||
> Status: FINALIZED
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> The last session should have set a goal. If not, find the spec that we should be implementing
|
||||
|
||||
## Plan
|
||||
|
||||
- **Anchor:** graduated Solution Design `coauthoring-native-surfaces.md` v0.2.1 (content repo, on main) — R2(a), ELIGIBLE per `check-issue-eligibility.sh`; lineage: sessions 0062 (spec) → 0063 (v0.2.0) → 0064 (spike + plan approved).
|
||||
- **Goal:** execute the reviewed implementation plan `plans/2026-07-01-native-surfaces-migration.md` — 9 tasks in §7.1 rung-4 order — and land it (no §9 web pipeline: VS Code extension, spec §7.2; done = merged + suites green + rung-3 smoke).
|
||||
- **Isolation:** sessions 0061/0062/0063 live-concurrent; 0062 occupied the primary checkout → claim refused shared tree (plugin #134) → EnterWorktree `session-native-surfaces-exec`, branch renamed `session-0065`.
|
||||
- Approved deliberate changes carried by the plan: INV-18 retirement (no machine-landing baseline advance); D15 deferred as issue #69.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Plugin repo `main` at `ef61b14` (0064's plan-archive commit); clean; 265 unit + full host E2E green at baseline (verified before any work).
|
||||
- Content repo `main` at `ddaf3b0` (0064 lineage merged); local content clone behind origin + carrying other sessions' uncommitted drafts — never touched; spec read via `git show origin/main:`.
|
||||
|
||||
## Arc (turn-by-turn, compressed)
|
||||
|
||||
1. **Init:** goal recovered from memory (0064 note). Claim dry-run → shared-checkout refusal → worktree isolation → claimed **0065** (`26b2212`). Anchor gate: ELIGIBLE (R2a). Baseline verified green.
|
||||
2. **Execution model:** superpowers:subagent-driven-development — fresh implementer subagent per task, task-scoped reviewer (spec + quality verdicts), fix subagents on findings, re-review loops; durable ledger `.superpowers/sdd/progress.md`; final whole-branch review at the end. Models tiered (sonnet implementers; opus for risky-diff reviews; fable for the final review).
|
||||
3. **Task 1 — CoeditingRegistry (INV-10 gate):** `2597cba`; review found untested `syncContext` context-key derivation (plan-mandated gap) → fix `de83757` (11 unit tests). Approved.
|
||||
4. **Task 2 — baseline router (INV-7):** `447a117`. Deviations approved by review: `.git/logs/HEAD` reflog watcher nudging `repo.status()` (vscode.git's watcher missed external commits in the host; change detection still keyed off `state.HEAD.commit`), E2E suites gaining enter-calls early. Machine-landing advance deleted; `markReviewed` replaces `pinDiffBaseline`. Approved.
|
||||
5. **Task 3 — native diff surface:** `cf65528` (QuickDiff, `cowriting-baseline:` provider, Review Changes, status bar, pure `countLineHunks`). Approved.
|
||||
6. **Task 4 — gate every surface:** `a323b82`; review confirmed a PUC-7 restore-on-enter gap (proposals/attribution) → fix `a36353d` (registry subscribers + regression E2E verified fail-without-fix). Approved.
|
||||
7. **Plan-file repair:** fused `---### Task 5` heading broke the task extractor → `f23fc4a`.
|
||||
8. **Task 5 — EditFlow extraction:** `ad5d34b`; review caught a real regression (palette accept/reject-all no longer repainted an open review panel) → fix `8fbbe45`. Approved.
|
||||
9. **Task 6 — comments-first ask loop:** `75f4a3c`; review found a **Critical**: `cowriting.edit`'s no-selection route (⌘⌥E) dead-ended in the rejecting input stub → fix `c9975ba` (all ask entry points converge on `ThreadController.askClaude`; machine-author guard; offer-WeakMap cleanup). Approved. `editInstructionInput.ts` deleted.
|
||||
10. **Task 7 — built-in preview annotations:** `17fc01e` (pure markdown-it plugin; sentinel machinery generalized 3-way, backward-compatible — 84/84 old tests unedited; render-wrapper approach correctly replacing the brief's per-token rule; mermaid rendering via `markdown.previewScripts` — Q4 confirmed). Approved; intra-diagram diff scoped out by the implementer.
|
||||
11. **Task 8 — webview sunset:** `2170a0d` (−1472 lines; coverage moved in-commit, arithmetic reconciled exactly). Review flagged the Task-7 scope-out as an unsanctioned INV-29 retirement + `renderReview` going production-dead. **Adjudication:** plan T7 §2.6 mandated the re-emit and its stop condition never triggered → fix `705de31` (diffMermaid wired into the fence path, 6 ported unit tests) + hardening `ff42b5a` (body-matched queue; misalignment degrades to verbatim; parse-less re-render reset). `renderReview` kept deliberately (see Deferred decisions). Approved.
|
||||
12. **Task 9 — full-loop E2E + smoke:** `22c9da0` (PUC-7→8→2→1 walk; `scripts/smoke-native-loop.mjs` + `npm run smoke:native`, operator-run). Review verified the implementer's adjudication (interior tweak → anchor orphans by contract; Keep-by-id survives) and surfaced a **pre-existing INV-5 gap** (reject-after-interior-edit) → plan text corrected (`17366a4`) + **issue #70 filed** (bug P2).
|
||||
13. **Final whole-branch review (fable):** verdict "with fixes" — Critical (preview-annotation host leaked past INV-10: wrong-doc annotation + stale-after-exit), Important (thread contextValue conflation made Resolve/Reopen permanently unmatchable once Claude replied; coedited docs opened post-activation never got a baseline) → ONE fix wave `0d69a29` (all three at root + regression E2Es + fold-in minors + polish). Re-review: full when-clause state matrix verified, no double-establish race, **Ready to merge: Yes**. **Issue #71 filed** (surface-polish batch).
|
||||
14. **Ship:** final verification (typecheck, 301 unit, 90+5 E2E, build — all green) → pushed → **PR #72** → **squash-merged to main `935fcc3`** → remote branch deleted → plan re-archived to content repo (`b0687b6`).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **`renderReview` left in place though production-dead after the Task 8 webview sunset.** The old F10 pure render engine (`renderReview`/`renderOp` in `trackChangesModel.ts`) lost its last production consumer when `trackChangesPreview.ts` was deleted; three E2E suites (authorship, s48PinClean, undoMarks) now probe it directly as a pure module via a test helper. I kept it (tested pure module, INV-33 documentation value, low carrying cost) rather than deleting + reworking those suites mid-migration. Operator may prefer deletion as a follow-up cleanup.
|
||||
- **INV-29 mermaid-diff parity adjudicated as plan-mandated, not retired.** Task 7 scoped out the F7.1 re-emit for testability; the plan's stop condition (previewScripts unworkable) never triggered, so I dispatched a fix to wire `mermaidDiff` into the preview fence path rather than accepting the silent retirement. Judged from plan T7 §2.6 + the plan's spec-coverage self-check.
|
||||
|
||||
## Cut state
|
||||
|
||||
- Plugin `main` = `935fcc3` (PR #72 squash). Working tree clean. Remote `session-0065` deleted; **local branch ref `session-0065` remains** (squash → plain `-d` refuses; operator has preferred manual cleanup of such refs).
|
||||
- Content repo `main` = `b0687b6` (plan re-archived with in-execution edits: heading fix + Task 9 wording correction).
|
||||
- Suites at HEAD: typecheck · 301 unit · 90 + 5 host E2E (1 env-conditional #54 undo-probe pending on some runs) · esbuild — all green.
|
||||
- Issues: **#70 filed** (INV-5 reject-after-interior-edit, bug P2), **#71 filed** (surface polish, task P3); #69 (D15) pre-existing.
|
||||
- Sessions 0061/0062/0063 still INPROGRESS (other Claude sessions); their checkouts/branches untouched.
|
||||
|
||||
## Operator plate
|
||||
|
||||
1. **`npm run smoke:native`** — rung-3 real-SDK smoke through the comment loop (one green run completes the plan's definition of done; report it to this transcript's home if you want the record complete).
|
||||
2. **Mermaid-preview GUI smoke** — open a coedited doc with a changed ```mermaid fence in the built-in preview; confirm diagram renders with cwAdded/ghost styling + legend (previewScripts path isn't E2E-queryable).
|
||||
3. Optional cleanup: `git branch -D session-0065` in the plugin clone; delete the now-merged worktree leftovers if any; #71 when convenient.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/goal Plan and execute #70 — fix the INV-5 reject-after-interior-edit gap (proposalController revertInPlace orphan path); fix directions in the issue body
|
||||
```
|
||||
@@ -0,0 +1,113 @@
|
||||
# Session 0066.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-07-02T16-24 (PST)
|
||||
> End: 2026-07-02T18-54 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: 14c9f9cc-92a1-47ef-a12d-9b0ac2469e13
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin/.claude/worktrees/fix-70-inv5-reject-gap
|
||||
> Status: FINALIZED
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
/goal Plan and execute #70 — fix the INV-5 reject-after-interior-edit gap (proposalController revertInPlace orphan path); fix directions in the issue body
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
> Anchor: #70 (type/bug, P2 — ELIGIBLE R2b)
|
||||
|
||||
Fix the INV-5 reject-after-interior-edit gap: `revertInPlace` in
|
||||
`src/proposalController.ts` skipped the restore when an interior edit inside
|
||||
the pending proposal's range orphaned the exact-substring anchor (INV-11),
|
||||
yet removed the proposal and reported success — ✗ Reject silently left the
|
||||
(edited) proposed text in the buffer. Evaluate the issue's fix directions
|
||||
(a/b/c), TDD the fix, ship branch → PR → merge per §5.4.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- `origin/main` at `d8f2d73` (session 0065's native-surfaces migration
|
||||
shipped; #70/#71 filed from its final review).
|
||||
- Three concurrent sessions in flight (0061, 0062, 0063 placeholders);
|
||||
session 0062 occupied the canonical checkout, so the #134 claim guard
|
||||
refused a shared-tree claim — this session created worktree
|
||||
`.claude/worktrees/fix-70-inv5-reject-gap` off `origin/main` FIRST and
|
||||
claimed 0066 from inside it (the inverted order the init documents).
|
||||
- Canonical checkout's untracked files (`specs/`, a plan doc) belong to the
|
||||
concurrent session and were left untouched.
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Init.** Claimed 0066 from the worktree; #70 checked ELIGIBLE (type/bug,
|
||||
R2b; needed `--host git.benstull.org` + the issues token service). Fetched
|
||||
the issue body carrying the three fix directions.
|
||||
2. **Design.** Explored `proposalController.ts` / `anchorer.ts` /
|
||||
`editorProposalController.ts`. Chose direction **(a)** — tracked applied
|
||||
range — with **(b)** honest hard failure as fallback; rejected **(c)**
|
||||
fuzzy resolve (INV-11: never guess). Key insight: `state.live` is
|
||||
shift-maintained but clobbered to a collapsed placeholder by any
|
||||
intervening `renderAll`, so a dedicated `appliedSpans` map with explicit
|
||||
trust semantics was designed instead.
|
||||
3. **Plan.** `docs/superpowers/plans/2026-07-02-70-inv5-reject-after-interior-edit.md`
|
||||
(4 TDD tasks). Fused single-session path (leaf bug), executed inline.
|
||||
4. **Tasks 1–4** (commits `a9f8dd0`, `82cea74`, `ddd634b`, `f47dfbc`):
|
||||
appliedSpans + revertInPlace fallback (E2E repro failed-then-passed);
|
||||
hard-failure lock test (first cut used a whole-buffer rewrite and FAILED —
|
||||
VS Code minimizes workspace edits, so it decomposed into interior hunks
|
||||
the span legitimately survives; switched to a boundary-straddling
|
||||
deletion); rejectAll `{reverted, skipped}` parity; fullLoop reject leg now
|
||||
tweaks inside the pending range first (retiring its deliberate avoidance).
|
||||
5. **Review wave** (high-effort multi-agent workflow; 10 CONFIRMED findings,
|
||||
9 fixed in `e389406`): pure `shiftTracked` in anchorer (+11 unit tests;
|
||||
insertion-at-span-start lands BEFORE the span), tracked spans cleared on
|
||||
doc close (disk can change with no events), rebuild-only resync (duplicate
|
||||
-text resolve can't clobber continuous tracking), `guard.isReadOnly` on
|
||||
finalize/revert (INV-16), "Discard proposal (leave text)" action on the
|
||||
hard-fail warning (reload leaves no other dismissal path), CodeLens pair
|
||||
anchored at the tracked span (the fixed path stays reachable from the
|
||||
primary gesture), QuickPick batch menu routed through the reporting
|
||||
commands, one `clearProposal` helper (fixes `reject()`'s stale bookkeeping).
|
||||
The 10th finding is pre-existing → filed **#74**.
|
||||
6. **Ship.** PR #73 → squash-merged to `main` as `7583165`; #70 auto-closed.
|
||||
312 unit + 94/5 host E2E green (1 occasionally pending = the known #54
|
||||
undo-capability probe). Plan archived to the content repo
|
||||
(`plans/2026-07-02-70-inv5-reject-after-interior-edit.md` at `0019d58`).
|
||||
Remote branch `s70-inv5-reject-gap` deleted after merge.
|
||||
|
||||
## Deployment pipeline (§9)
|
||||
|
||||
This app is a VS Code extension with no deploy infra (no flotilla/PPE stage
|
||||
exists — per prior sessions, "no deploy pipeline (extension, no flotilla)").
|
||||
The localhost stage ran in full: typecheck + 312 unit + 94/5 host E2E green.
|
||||
Nothing to deploy; no release tag applies.
|
||||
|
||||
## Cut state
|
||||
|
||||
- `origin/main` = `7583165` (the #70 fix). Worktree branch merged; remote
|
||||
branch deleted; worktree torn down at finalize.
|
||||
- Issue #70 CLOSED (auto, via PR). Issue #74 (bug P3) OPEN — filed this
|
||||
session: `resolve()` accepts a single exact occurrence without a context
|
||||
check (pre-existing; duplicated block can hijack any anchor consumer).
|
||||
- Concurrent sessions 0061/0062/0063 still INPROGRESS — not mine to adopt
|
||||
(live, per their stamps).
|
||||
|
||||
## Operator plate
|
||||
|
||||
- **Manual smoke owed:** the "Discard proposal (leave text)" warning button
|
||||
(native notification — not E2E-testable) + the smokes already owed from
|
||||
sessions 0062/0065.
|
||||
- Prod promotion: N/A (no deploy pipeline for the extension).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
No low-confidence calls this session. (Design forks — (a)+(b) over (c), the
|
||||
distrust semantics, squash-merge — all followed the issue's own directions,
|
||||
existing invariants, or repo convention.)
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/goal Plan and execute #71 — surface-polish batch from the native-surfaces final review (6 small items listed in the issue body)
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
# Session 0067.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-07-02T20-07 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: d29a9976-cb2d-427b-9ef1-4488b3c2a262
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin/.claude/worktrees/s71-surface-polish
|
||||
> End: 2026-07-03T07-39 (PST)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Plan and execute #71 — surface-polish batch from the native-surfaces final review (6 small items listed in the issue body)
|
||||
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
> Anchor: #71 (type/task, P3 — ELIGIBLE R2b)
|
||||
|
||||
Plan and execute #71 — the surface-polish batch from the native-surfaces
|
||||
migration final review (session 0065). Six small items from the issue body:
|
||||
|
||||
1. `markReviewed`/`pin()` misleading warning on never-established docs (`src/diffViewController.ts`)
|
||||
2. Delete orphaned `cowriting.acceptProposal`/`rejectProposal` declarations (`package.json`)
|
||||
3. `openReviewPreview` editor/title when-key: `editorLangId` → `resourceLangId`
|
||||
4. README F3 section drift: SUPERSEDED banner + stale chord references
|
||||
5. Gate/warn `cowriting.createThread` palette entry when not coediting
|
||||
6. Clear `gitBaseline` reflog debounce timer in `dispose()` (`src/gitBaseline.ts`)
|
||||
|
||||
Fused session (trivial leaf batch): just-in-time plan → execute → tests green
|
||||
→ PR → merge. Isolated worktree `s71-surface-polish` (checkout contended by
|
||||
live session 0062).
|
||||
|
||||
## Execution record
|
||||
|
||||
- Plan written (`docs/superpowers/plans/2026-07-02-71-surface-polish.md`, committed
|
||||
on the branch) and executed inline — all six items landed as one commit each:
|
||||
1. `pin()` not-coediting branch → "Run ✦ Coedit this Document with Claude first."
|
||||
2. orphaned `acceptProposal`/`rejectProposal` declarations + `when:false` palette
|
||||
entries deleted
|
||||
3. `openReviewPreview` editor/title when-key `editorLangId` → `resourceLangId`
|
||||
(+ the E2E assertion at `reviewPanelMenu.test.ts:129` that codified the old
|
||||
key — the one mid-run E2E failure, fixed forward)
|
||||
4. README: F3 superseded-commands banner; F7/F10 banner chord notes
|
||||
5. `createThread` palette entry gated (same when as its editor/context entry)
|
||||
6. `gitBaseline` reflog debounce timer cleared on dispose
|
||||
- Verification: 312 unit + typecheck + build + full host E2E green.
|
||||
- Shipped: PR #75 squash-merged to `main` as `3e7ad58`; issue #71 auto-closed;
|
||||
remote branch deleted. No flotilla/PPE pipeline — this app is a VS Code
|
||||
extension with no deployed service (§9 ends at merge + green suites here).
|
||||
|
||||
## Post-ship arc (operator review + Q&A)
|
||||
|
||||
- Launched the EDH from the worktree for operator review
|
||||
(`code --extensionDevelopmentPath=<worktree> <worktree>/sandbox`; VS Code
|
||||
found at `~/Downloads/Visual Studio Code.app`).
|
||||
- Operator Q&A, all assessment-only: (1) comment box is the native Comments
|
||||
API widget — Cmd+Enter submits, Enter newlines; operator chose to keep the
|
||||
native mapping. (2) macOS "access data from other apps" TCC prompt — App
|
||||
Data Protection category, distinct from #59; VS Code found QUARANTINED +
|
||||
App-Translocated (runs from ~/Downloads) — recommended move to
|
||||
/Applications; #59 env guard confirmed present (`liveTurn.ts:82-83`).
|
||||
(3) External (outside-editor) Claude edits: detected via the disk-sync
|
||||
branch (`attributionController.ts:175`), rendered neutral, never attributed
|
||||
— cross-rung sidecar is the designed path to attributed external edits.
|
||||
(4) `onDidChangeTextDocument` carries no originator identity (why the seam
|
||||
pre-registers edits, INV-9). (5) Sketched per-author diff surfaces
|
||||
(synthetic per-author baselines + second QuickDiffProvider). (6) Explained
|
||||
tweak-before-accept tracking (mixed authorship; #70 appliedSpans; reject
|
||||
reverts whole span or refuses).
|
||||
- The Q&A converged on a product pivot the operator dictated: simplify review
|
||||
to baseline-diff Keep/Reject (three-verb toolbar, no annotations, no
|
||||
preview). Handed off to capture session 0068 (draft written, NOT filed) and
|
||||
an implementation prompt.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- design-readiness soft gate returned MISSING for #71 — proceeded: all six
|
||||
items are copy/menu-gating/cleanup within existing surfaces, no new UI
|
||||
beyond the design system (R4 not implicated).
|
||||
|
||||
## Closeout
|
||||
|
||||
Shipped: PR #75 (squash `3e7ad58`), issue #71 closed, remote branch deleted.
|
||||
Suites: 312 unit + typecheck + build + full host E2E green (one mid-run E2E
|
||||
fix: `reviewPanelMenu.test.ts:129` had codified the wrong when-key). Plan
|
||||
archived to content repo `plans/2026-07-02-71-surface-polish.md`. No
|
||||
flotilla/PPE stage exists for this app (VS Code extension, no deployed
|
||||
service) — §9 ends at merge + green suites. Worktree `s71-surface-polish` is
|
||||
merged-clean but KEPT at finalize: the operator's EDH review window still runs
|
||||
from it (`--extensionDevelopmentPath`); remove after closing that window
|
||||
(`git worktree remove .claude/worktrees/s71-surface-polish`). Operator plate:
|
||||
close the EDH then remove the worktree; move VS Code.app out of ~/Downloads to
|
||||
/Applications (translocation/TCC); decide on the 0068 unfiled draft.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
/goal Resume the capture: file the unfiled draft
|
||||
issues/simplify-review-baseline-diff-keep-reject.md as type/feature P1 on
|
||||
benstull/vscode-cowriting-plugin, then brainstorm its Solution Design
|
||||
(specs/coauthoring-baseline-diff-review.md) on the new issue, locking the five
|
||||
forks in the draft's Solution notes.
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Appended as the session runs; surfaced at
|
||||
finalize. Empty if none._
|
||||
@@ -0,0 +1,50 @@
|
||||
# Session 0068.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-07-03T06-53 (PST)
|
||||
> Type: capture
|
||||
> Posture: careful
|
||||
> Claude-Session: d29a9976-cb2d-427b-9ef1-4488b3c2a262
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin/.claude/worktrees/s71-surface-polish
|
||||
> End: 2026-07-03T07-39 (PST)
|
||||
> Status: **FINALIZED — tracked-lite (capture); NOTHING FILED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Capture one type/feature issue: simplify the review model to baseline-diff Keep/Reject — comments to ask Claude, native diff to review (per-hunk Keep/Reject/Modify, Keep advances the baseline), no annotations, no preview surface; toolbar reduces to Ask Claude / Review Changes / Mark All Changes as Reviewed. Context developed in conversation following session 0067; brainstorming session to follow on the filed issue.
|
||||
|
||||
```
|
||||
|
||||
## Arc
|
||||
|
||||
- Claimed 0068 (capture, careful) from the s71 worktree; resolved content repo
|
||||
`benstull/vscode-cowriting-plugin-content`. Detector listed ~24 pre-#80
|
||||
unfiled-marker drafts for long-filed issues (operator-owned backlog hygiene
|
||||
— untouched).
|
||||
- Drafted `issues/simplify-review-baseline-diff-keep-reject.md` per the
|
||||
feature batching profile: business block → operator approved ("Looks
|
||||
good") → structural block + solution notes (approach pre-validated in the
|
||||
originating 0067 conversation; five design forks recorded).
|
||||
- **Filing withheld (INV-1):** at the file-on-approval gate the operator
|
||||
pivoted — "Just give me the prompt for implementing" — so NO issue was
|
||||
filed. Delivered the implementation prompt (unattended design→build chain
|
||||
with named yolo override, plus the two-step interactive variant), with `#N`
|
||||
pending the eventual filing.
|
||||
- Finalize: draft left in the content-repo working tree as the resumable
|
||||
surface (never auto-discarded); type-fit judge not run (nothing filed);
|
||||
type-fit: judged 0, mismatch 0, overridden 0.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — careful posture; every gate was operator-answered. The one open
|
||||
operator decision (file the draft or not) is deliberately left open and
|
||||
carried in the next-session prompt._
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
/goal Resume the capture: file the unfiled draft
|
||||
issues/simplify-review-baseline-diff-keep-reject.md as type/feature P1 on
|
||||
benstull/vscode-cowriting-plugin, then brainstorm its Solution Design
|
||||
(specs/coauthoring-baseline-diff-review.md) on the new issue, locking the five
|
||||
forks in the draft's Solution notes.
|
||||
@@ -166,5 +166,41 @@
|
||||
},
|
||||
"0056": {
|
||||
"title": ""
|
||||
},
|
||||
"0057": {
|
||||
"title": ""
|
||||
},
|
||||
"0058": {
|
||||
"title": ""
|
||||
},
|
||||
"0059": {
|
||||
"title": ""
|
||||
},
|
||||
"0060": {
|
||||
"title": ""
|
||||
},
|
||||
"0061": {
|
||||
"title": ""
|
||||
},
|
||||
"0062": {
|
||||
"title": ""
|
||||
},
|
||||
"0063": {
|
||||
"title": ""
|
||||
},
|
||||
"0064": {
|
||||
"title": ""
|
||||
},
|
||||
"0065": {
|
||||
"title": ""
|
||||
},
|
||||
"0066": {
|
||||
"title": ""
|
||||
},
|
||||
"0067": {
|
||||
"title": ""
|
||||
},
|
||||
"0068": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,30 @@ export function resolve(docText: string, fp: Fingerprint): OffsetRange | "orphan
|
||||
return "orphaned";
|
||||
}
|
||||
|
||||
/**
|
||||
* #70 (INV-5/INV-11): maintain a TRACKED applied span across an in-session edit.
|
||||
* Unlike `shift`, which clamps every point into a best-effort range, a tracked
|
||||
* span must stay *provably* meaningful — it is a revert target. So:
|
||||
* - an edit fully OUTSIDE or fully INSIDE the span shifts it (the span is
|
||||
* still "the applied block, as tweaked");
|
||||
* - an insertion exactly AT the span start lands BEFORE the span (both ends
|
||||
* shift right) — matching exact-resolve semantics, where text typed just
|
||||
* before the applied block is never part of it;
|
||||
* - any edit STRADDLING a span boundary returns "distrusted": the shifted
|
||||
* result would be a clamped guess, and stale text is never reverted by
|
||||
* guess (INV-11).
|
||||
*/
|
||||
export function shiftTracked(range: OffsetRange, edit: TextEdit): OffsetRange | "distrusted" {
|
||||
const delta = edit.newLength - (edit.end - edit.start);
|
||||
if (edit.start === edit.end && edit.start === range.start) {
|
||||
return { start: range.start + delta, end: range.end + delta };
|
||||
}
|
||||
const outside = edit.end <= range.start || edit.start >= range.end;
|
||||
const inside = edit.start >= range.start && edit.end <= range.end;
|
||||
if (!outside && !inside) return "distrusted";
|
||||
return shift(range, edit);
|
||||
}
|
||||
|
||||
/** Maintain a live range across an in-session edit (spec §6.4 `shift`). */
|
||||
export function shift(range: OffsetRange, edit: TextEdit): OffsetRange {
|
||||
const delta = edit.newLength - (edit.end - edit.start);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
|
||||
import type { VersionGuard } from "./versionGuard";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { AuthorSpan } from "./trackChangesModel";
|
||||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
||||
|
||||
/** Test-facing snapshot of live attribution state for a document. */
|
||||
export interface RenderedSpan {
|
||||
@@ -56,8 +57,11 @@ export class AttributionController implements vscode.Disposable {
|
||||
/**
|
||||
* F6 (§6.2/§6.4): the single machine-landing signal. Fired after a real
|
||||
* (non-no-op) seam apply succeeds. INV-9 makes the seam the sole machine-edit
|
||||
* ingress, so this is the sole signal — DiffViewController subscribes to
|
||||
* advance the baseline; no call-site wiring, no future driver can forget it.
|
||||
* ingress, so this is the sole signal. Native-surfaces migration (INV-7/
|
||||
* INV-18 retirement, spec §6.4): DiffViewController no longer subscribes
|
||||
* here to advance the baseline — a landed edit stays a visible
|
||||
* change-since-baseline until commit (head mode) or "Mark Changes as
|
||||
* Reviewed" (snapshot mode). Other consumers may still subscribe.
|
||||
*/
|
||||
private readonly applyEmitter = new vscode.EventEmitter<{ document: vscode.TextDocument }>();
|
||||
readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event;
|
||||
@@ -66,12 +70,35 @@ export class AttributionController implements vscode.Disposable {
|
||||
private readonly store: SidecarRouter,
|
||||
private readonly rootDir: string | undefined,
|
||||
private readonly guard: VersionGuard,
|
||||
private readonly registry: CoeditingRegistry,
|
||||
) {
|
||||
this.disposables.push(this.statusItem, this.output, this.applyEmitter);
|
||||
this.disposables.push(
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
|
||||
vscode.window.onDidChangeActiveTextEditor(() => this.renderActive()),
|
||||
// PUC-7 restore-on-enter: `loadAll` is the only path that (re)populates
|
||||
// s.spans/s.orphans from the sidecar — without this, a document's FIRST
|
||||
// entry into coediting (registry.enter(), extension.ts) never runs it,
|
||||
// so pre-existing committed authorship never surfaces until the next
|
||||
// edit. Mirrors ThreadController's own registry subscription; `loadAll`
|
||||
// already gates on isCoediting, and exit intentionally does nothing here
|
||||
// (no data to wipe — EditorProposalController's own gate clears the
|
||||
// decorations that read spansFor). Registered before ProposalController/
|
||||
// EditorProposalController (construction order in extension.ts) so their
|
||||
// renders see fresh spans on the same enter transition.
|
||||
this.registry.onDidChange(({ uri, coediting }) => {
|
||||
if (!coediting) {
|
||||
// Finding 5 (final whole-branch review): exit hides the status-bar
|
||||
// item too — otherwise a stale "N orphaned attributions" warning
|
||||
// for a doc that's no longer even coediting stays pinned in the bar
|
||||
// until some unrelated render happens to fire for it.
|
||||
if (vscode.window.activeTextEditor?.document.uri.toString() === uri) this.statusItem.hide();
|
||||
return;
|
||||
}
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (doc) this.loadAll(doc);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,6 +128,7 @@ export class AttributionController implements vscode.Disposable {
|
||||
/** Load the sidecar and re-resolve every attribution (live span | orphan). */
|
||||
loadAll(document: vscode.TextDocument): void {
|
||||
if (!this.isTracked(document)) return;
|
||||
if (!this.registry.isCoediting(document.uri)) return;
|
||||
const docPath = this.keyOf(document);
|
||||
const s = this.state(docPath);
|
||||
const artifact = this.store.load(docPath);
|
||||
@@ -142,6 +170,7 @@ export class AttributionController implements vscode.Disposable {
|
||||
|
||||
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
||||
if (!this.registry.isCoediting(e.document.uri)) return;
|
||||
const docPath = this.keyOf(e.document);
|
||||
if (!e.document.isDirty && this.matchesDisk(e.document)) {
|
||||
// Disk sync (revert / external reload): buffer now equals the file on
|
||||
@@ -249,7 +278,7 @@ export class AttributionController implements vscode.Disposable {
|
||||
range: vscode.Range,
|
||||
newText: string,
|
||||
provenance: Provenance,
|
||||
opts?: { expectedVersion?: number; turnId?: string },
|
||||
opts?: { expectedVersion?: number; turnId?: string; landBaseline?: boolean },
|
||||
): Promise<boolean> {
|
||||
if (!this.isTracked(document)) return false;
|
||||
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
|
||||
@@ -290,15 +319,28 @@ export class AttributionController implements vscode.Disposable {
|
||||
"(host minimized differently?) — the edit may be mis-attributed (INV-9).",
|
||||
);
|
||||
}
|
||||
if (ok) {
|
||||
// F6 (INV-18): a real machine landing — signal the baseline to advance so
|
||||
// this text never shows as a change in the diff view. Fire regardless of
|
||||
// attribution-match bookkeeping above; the landing happened either way.
|
||||
if (ok && opts?.landBaseline !== false) {
|
||||
// F6 (INV-18, retired as a baseline advance — spec §6.4/INV-7): fire the
|
||||
// machine-landing signal. F12 (INV-48) suppresses this for optimistic
|
||||
// apply: the proposed text is in the buffer but the change stays PENDING
|
||||
// until accept.
|
||||
this.applyEmitter.fire({ document });
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 marks the landing so the
|
||||
* now-accepted change is recorded (INV-7: the baseline itself no longer
|
||||
* advances on landing — it advances only on commit / "Mark Changes as
|
||||
* Reviewed").
|
||||
*/
|
||||
signalLanded(document: vscode.TextDocument): void {
|
||||
if (this.isTracked(document)) this.applyEmitter.fire({ document });
|
||||
}
|
||||
|
||||
// ---- PUC-4: persistence on save ----------------------------------------------------
|
||||
|
||||
private onDidSave(document: vscode.TextDocument): void {
|
||||
|
||||
+17
-2
@@ -7,11 +7,19 @@
|
||||
* key is a hash of the document URI (the controller derives it), not a
|
||||
* workspace-relative path; an untitled buffer has no durable identity and is
|
||||
* never persisted here (in-memory only — see DiffViewController).
|
||||
*
|
||||
* INV-7 (native-surfaces migration, spec §6.4): the baseline is now either the
|
||||
* git-HEAD blob (never persisted — see GitBaselineAdapter/DiffViewController)
|
||||
* or a snapshot captured on coediting-entry / re-pinned by "Mark Changes as
|
||||
* Reviewed". `"opened"`/`"machine-landing"` are legacy reasons from the
|
||||
* shipped #17/#48 baseline; `normalizeReason` migrates them on load so old
|
||||
* on-disk baselines keep round-tripping under the new vocabulary.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export type BaselineReason = "opened" | "machine-landing" | "pinned";
|
||||
export type BaselineReason = "entered" | "pinned" | "head";
|
||||
type LegacyBaselineReason = BaselineReason | "opened" | "machine-landing";
|
||||
|
||||
export interface Baseline {
|
||||
/** the document URI (the identity the key is derived from) — for round-trip/debug. */
|
||||
@@ -22,6 +30,12 @@ export interface Baseline {
|
||||
reason: BaselineReason;
|
||||
}
|
||||
|
||||
/** Migrate legacy on-disk reasons to the current vocabulary (§6.4 INV-7). */
|
||||
export function normalizeReason(reason: LegacyBaselineReason): BaselineReason {
|
||||
if (reason === "opened" || reason === "machine-landing") return "entered";
|
||||
return reason;
|
||||
}
|
||||
|
||||
export class BaselineStore {
|
||||
/** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */
|
||||
constructor(private readonly storageDir: string) {}
|
||||
@@ -34,7 +48,8 @@ export class BaselineStore {
|
||||
load(key: string): Baseline | null {
|
||||
const p = this.baselinePath(key);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return JSON.parse(fs.readFileSync(p, "utf8")) as Baseline;
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf8")) as Baseline;
|
||||
return { ...raw, reason: normalizeReason(raw.reason) };
|
||||
}
|
||||
|
||||
/** Newest epoch wins — overwrite in place, no history (state-not-history, the F3/F4 precedent). */
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* CoeditingRegistry — the INV-10 activation gate (spec §6.4). Holds the set of
|
||||
* documents the writer has explicitly entered into coediting; every surface
|
||||
* (SCM/QuickDiff, comments, preview annotations, edit commands, decorations)
|
||||
* checks membership before attaching. Persisted in workspaceState so a reload
|
||||
* restores the set (the durable sidecar carries the artifacts; this carries
|
||||
* only the mode).
|
||||
*/
|
||||
import * as vscode from "vscode";
|
||||
|
||||
const STATE_KEY = "cowriting.coeditingSet";
|
||||
|
||||
export class CoeditingRegistry implements vscode.Disposable {
|
||||
private readonly set: Set<string>;
|
||||
private readonly emitter = new vscode.EventEmitter<{ uri: string; coediting: boolean }>();
|
||||
readonly onDidChange = this.emitter.event;
|
||||
/** Set by the SCM surface (Task 3) so syncContext can expose the baseline mode. */
|
||||
baselineModeOf: ((uri: vscode.Uri) => "head" | "snapshot" | undefined) | undefined;
|
||||
|
||||
constructor(private readonly state: vscode.Memento) {
|
||||
this.set = new Set(state.get<string[]>(STATE_KEY, []));
|
||||
}
|
||||
|
||||
enter(uri: vscode.Uri): void {
|
||||
const key = uri.toString();
|
||||
if (this.set.has(key)) return;
|
||||
this.set.add(key);
|
||||
void this.state.update(STATE_KEY, [...this.set]);
|
||||
this.emitter.fire({ uri: key, coediting: true });
|
||||
}
|
||||
|
||||
exit(uri: vscode.Uri): void {
|
||||
const key = uri.toString();
|
||||
if (!this.set.delete(key)) return;
|
||||
void this.state.update(STATE_KEY, [...this.set]);
|
||||
this.emitter.fire({ uri: key, coediting: false });
|
||||
}
|
||||
|
||||
isCoediting(uri: vscode.Uri): boolean {
|
||||
return this.set.has(uri.toString());
|
||||
}
|
||||
|
||||
list(): string[] {
|
||||
return [...this.set];
|
||||
}
|
||||
|
||||
syncContext(editor: vscode.TextEditor | undefined): void {
|
||||
const coediting = !!editor && this.isCoediting(editor.document.uri);
|
||||
void vscode.commands.executeCommand("setContext", "cowriting.isCoediting", coediting);
|
||||
const mode = coediting && editor ? this.baselineModeOf?.(editor.document.uri) : undefined;
|
||||
void vscode.commands.executeCommand("setContext", "cowriting.baselineMode", mode ?? "");
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.emitter.dispose();
|
||||
}
|
||||
}
|
||||
+132
-43
@@ -1,9 +1,16 @@
|
||||
/**
|
||||
* DiffViewController — F6 baseline data layer (spec §6.4/§6.7). Owns the baseline
|
||||
* lifecycle (initialize at first sight / advance at every machine landing / pin
|
||||
* on demand) and serves it to F7/F10 via `getBaseline` + the additive
|
||||
* `onDidChangeBaseline` event. A pure data layer: never mutates the document,
|
||||
* sidecar, or attribution state (INV-19).
|
||||
* DiffViewController — the baseline router (native-surfaces migration, spec
|
||||
* §6.4, INV-7). Resolves each coedited document's review baseline to one of two
|
||||
* modes: **head** (git-tracked — the baseline is the `HEAD` blob, re-read on
|
||||
* every commit, never persisted) or **snapshot** (untracked/no-repo — captured
|
||||
* on coediting-entry, re-pinned on demand by "Mark Changes as Reviewed").
|
||||
* Baselines are established ONLY for documents the CoeditingRegistry has
|
||||
* entered (INV-10) — opening a file no longer captures one (the retired
|
||||
* `ensureBaseline`/#17 behavior). The shipped machine-landing advance (#48,
|
||||
* INV-18) is retired too: a landed Claude edit stays a visible change-since-
|
||||
* baseline until the file is committed (head mode) or reviewed (snapshot
|
||||
* mode) — spec INV-7's note, D21. A pure data layer: never mutates the
|
||||
* document, sidecar, or attribution state (INV-19).
|
||||
*
|
||||
* The F6 two-pane `vscode.diff` *view* (toggle UI + the `cowriting-baseline:`
|
||||
* virtual document) was removed in #34 once F10 made the rendered preview the
|
||||
@@ -12,37 +19,57 @@
|
||||
* The baseline works on ANY text document, not just workspace files — it needs no
|
||||
* `.threads/` sidecar, only a stable doc identity + a storage home. So it has
|
||||
* its OWN diffable predicate (any `file:` or `untitled:` doc), decoupled from
|
||||
* F2's workspace `isTracked`. Persistable docs (`file:`) keep their baseline in
|
||||
* VS Code's per-extension GLOBAL storage keyed by a hash of the document URI;
|
||||
* untitled buffers have no durable identity, so their baseline is in-memory
|
||||
* only (lost on reload) — the same degrade the storage-unavailable path uses.
|
||||
* F2's workspace `isTracked`. Persistable docs (`file:`) keep their SNAPSHOT
|
||||
* baseline in VS Code's per-extension GLOBAL storage keyed by a hash of the
|
||||
* document URI (INV-7: head-mode baselines are storage-free — never
|
||||
* persisted); untitled buffers have no durable identity, so their baseline is
|
||||
* in-memory only (lost on reload) — the same degrade the storage-unavailable
|
||||
* path uses.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import * as vscode from "vscode";
|
||||
import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore";
|
||||
import type { GitBaselineAdapter } from "./gitBaseline";
|
||||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
||||
|
||||
export class DiffViewController implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
/** Source of truth for the baseline, keyed by `document.uri.toString()`. */
|
||||
private readonly baselines = new Map<string, Baseline>();
|
||||
/** F7 (additive): fires on every baseline capture (open / advance / pin) so
|
||||
* the track-changes preview refreshes without polling. Carries the real
|
||||
* document URI. */
|
||||
/** Which mode each established document resolved to (INV-7). */
|
||||
private readonly modes = new Map<string, "head" | "snapshot">();
|
||||
/** F7 (additive): fires on every baseline capture (establish / head-refresh /
|
||||
* pin) so the track-changes preview refreshes without polling. Carries the
|
||||
* real document URI. */
|
||||
private readonly onDidChangeBaselineEmitter = new vscode.EventEmitter<{ uri: string }>();
|
||||
readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event;
|
||||
private storageWarned = false;
|
||||
|
||||
constructor(private readonly store: BaselineStore | null) {
|
||||
constructor(
|
||||
private readonly store: BaselineStore | null,
|
||||
private readonly git: GitBaselineAdapter,
|
||||
registry: CoeditingRegistry,
|
||||
) {
|
||||
this.disposables.push(
|
||||
this.onDidChangeBaselineEmitter,
|
||||
vscode.commands.registerCommand("cowriting.pinDiffBaseline", () =>
|
||||
vscode.commands.registerCommand("cowriting.markReviewed", () =>
|
||||
this.pinCommand(vscode.window.activeTextEditor),
|
||||
),
|
||||
// F6 captures a baseline for any diffable doc the moment it is first seen,
|
||||
// independent of the workspace gate (so "opened" is the open-time text).
|
||||
vscode.workspace.onDidOpenTextDocument((d) => this.ensureBaseline(d)),
|
||||
// INV-10: a baseline is established only once its document ENTERS
|
||||
// coediting; exiting keeps the stored baseline as-is (PUC-7).
|
||||
registry.onDidChange(({ uri, coediting }) => {
|
||||
if (!coediting) return;
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (doc) void this.establish(doc);
|
||||
}),
|
||||
// A commit on a watched repo may advance a head-mode baseline (D13).
|
||||
git.onDidChangeHead(() => void this.refreshHeadBaselines()),
|
||||
);
|
||||
for (const d of vscode.workspace.textDocuments) this.ensureBaseline(d);
|
||||
// Restore already-coediting documents (e.g. after an extension host reload).
|
||||
for (const key of registry.list()) {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
|
||||
if (doc) void this.establish(doc);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- diffability / identity --------------------------------------------------------
|
||||
@@ -51,7 +78,7 @@ export class DiffViewController implements vscode.Disposable {
|
||||
private isDiffable(document: vscode.TextDocument): boolean {
|
||||
return document.uri.scheme === "file" || document.uri.scheme === "untitled";
|
||||
}
|
||||
/** Only `file:` docs have a durable identity to persist a baseline against. */
|
||||
/** Only `file:` docs have a durable identity to persist a SNAPSHOT baseline against. */
|
||||
private isPersistable(document: vscode.TextDocument): boolean {
|
||||
return document.uri.scheme === "file";
|
||||
}
|
||||
@@ -64,36 +91,72 @@ export class DiffViewController implements vscode.Disposable {
|
||||
return createHash("sha256").update(uriKey).digest("hex");
|
||||
}
|
||||
|
||||
// ---- baseline lifecycle (§6.4) -----------------------------------------------------
|
||||
// ---- baseline lifecycle (§6.4, INV-7) -----------------------------------------------
|
||||
|
||||
/** First sight of a diffable doc: load the stored baseline, else capture `opened`. */
|
||||
ensureBaseline(document: vscode.TextDocument): void {
|
||||
/**
|
||||
* Resolve this document's baseline mode and (re)establish its baseline.
|
||||
* git-tracked (a HEAD blob resolves) → **head** mode, baseline = HEAD, never
|
||||
* persisted. Otherwise → **snapshot** mode: load a previously-stored
|
||||
* snapshot, else capture the buffer now (`"entered"`). Called once when a
|
||||
* document enters coediting (registry.enter) and at startup for documents
|
||||
* already coediting.
|
||||
*/
|
||||
async establish(document: vscode.TextDocument): Promise<void> {
|
||||
if (!this.isDiffable(document)) return;
|
||||
const key = this.uriKey(document);
|
||||
if (this.baselines.has(key)) return;
|
||||
const head = await this.git.headFor(document.uri);
|
||||
if (head) {
|
||||
this.modes.set(key, "head");
|
||||
this.setBaseline(key, head.text, "head");
|
||||
return;
|
||||
}
|
||||
this.modes.set(key, "snapshot");
|
||||
if (this.store && this.isPersistable(document)) {
|
||||
try {
|
||||
const stored = this.store.load(this.storageKey(key));
|
||||
if (stored) {
|
||||
this.baselines.set(key, stored);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
this.warnStorageOnce();
|
||||
const stored = this.tryLoad(key); // existing load path, legacy reasons normalized
|
||||
if (stored) {
|
||||
this.baselines.set(key, stored);
|
||||
this.fire(key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.capture(document, "opened");
|
||||
this.capture(document, "entered");
|
||||
}
|
||||
|
||||
/** Machine landing (INV-18): re-capture so landed text never shows as a change. */
|
||||
advance(document: vscode.TextDocument): void {
|
||||
if (!this.isDiffable(document)) return;
|
||||
this.capture(document, "machine-landing");
|
||||
/** The mode a document's baseline resolved to, or undefined before `establish`. */
|
||||
modeOf(uriString: string): "head" | "snapshot" | undefined {
|
||||
return this.modes.get(uriString);
|
||||
}
|
||||
|
||||
/** Human pin: baseline := now; the preview's change-marks empty (left = right). */
|
||||
/** Re-read HEAD for every head-mode document; a commit advances the baseline (D13). */
|
||||
private async refreshHeadBaselines(): Promise<void> {
|
||||
for (const [key, mode] of this.modes) {
|
||||
if (mode !== "head") continue;
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
|
||||
if (!doc) continue;
|
||||
const head = await this.git.headFor(doc.uri);
|
||||
if (head && head.text !== this.baselines.get(key)?.text) {
|
||||
this.setBaseline(key, head.text, "head"); // commit advanced the baseline (D13)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Human pin ("Mark Changes as Reviewed", D14): snapshot mode only — a
|
||||
* git-tracked (head-mode) baseline advances only by commit. */
|
||||
pin(document: vscode.TextDocument): void {
|
||||
if (!this.isDiffable(document)) return;
|
||||
const key = this.uriKey(document);
|
||||
const mode = this.modes.get(key);
|
||||
if (mode === undefined) {
|
||||
// never established — the document is not being coedited (#71 item 1)
|
||||
void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first.");
|
||||
return;
|
||||
}
|
||||
if (mode !== "snapshot") {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: this document is git-tracked — commit to advance the baseline.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.capture(document, "pinned");
|
||||
}
|
||||
|
||||
@@ -103,12 +166,38 @@ export class DiffViewController implements vscode.Disposable {
|
||||
const baseline: Baseline = { uri: key, text: document.getText(), capturedAt: new Date().toISOString(), reason };
|
||||
this.baselines.set(key, baseline);
|
||||
if (this.store && this.isPersistable(document)) {
|
||||
try {
|
||||
this.store.save(this.storageKey(key), baseline);
|
||||
} catch {
|
||||
this.warnStorageOnce();
|
||||
}
|
||||
this.trySave(key, baseline);
|
||||
}
|
||||
this.fire(key);
|
||||
}
|
||||
|
||||
/** Set a baseline from resolved text directly (head mode — no document buffer read). */
|
||||
private setBaseline(key: string, text: string, reason: BaselineReason): void {
|
||||
const baseline: Baseline = { uri: key, text, capturedAt: new Date().toISOString(), reason };
|
||||
this.baselines.set(key, baseline);
|
||||
// head-mode baselines are storage-free (INV-7); persist only snapshots.
|
||||
if (reason !== "head" && this.store) this.trySave(key, baseline);
|
||||
this.fire(key);
|
||||
}
|
||||
|
||||
private tryLoad(key: string): Baseline | null {
|
||||
try {
|
||||
return this.store!.load(this.storageKey(key));
|
||||
} catch {
|
||||
this.warnStorageOnce();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private trySave(key: string, baseline: Baseline): void {
|
||||
try {
|
||||
this.store!.save(this.storageKey(key), baseline);
|
||||
} catch {
|
||||
this.warnStorageOnce();
|
||||
}
|
||||
}
|
||||
|
||||
private fire(key: string): void {
|
||||
this.onDidChangeBaselineEmitter.fire({ uri: key });
|
||||
}
|
||||
|
||||
@@ -122,7 +211,7 @@ export class DiffViewController implements vscode.Disposable {
|
||||
|
||||
private pinCommand(editor: vscode.TextEditor | undefined): void {
|
||||
if (!editor || !this.isDiffable(editor.document)) {
|
||||
void vscode.window.showWarningMessage("Cowriting: focus a text editor to pin its review baseline.");
|
||||
void vscode.window.showWarningMessage("Cowriting: focus a text editor to mark its changes as reviewed.");
|
||||
return;
|
||||
}
|
||||
this.pin(editor.document);
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* EditFlow — the host edit-turn flow (F11/F12), extracted from the review
|
||||
* webview controller ahead of its sunset (native-surfaces migration, spec
|
||||
* §6.10). Owns the low-level turn→proposal(s) cut (`runEditAndPropose`,
|
||||
* INV-39/40): never mutates the document directly — every turn lands as one or
|
||||
* more F4 proposals via `ProposalController.propose` (INV-10). Reachable, from
|
||||
* Task 6 on, from the thread controller (`ThreadController.runMakeEdit`) —
|
||||
* vscode-API-only, no webview state. The `cowriting.editDocument` command lives
|
||||
* in extension.ts (Finding-1 fix) so it can hand off to
|
||||
* `ThreadController.askClaude()`. Task 8 deleted this class's own
|
||||
* `askClaude`/`askEditInstruction` (the review webview's toolbar ask) once
|
||||
* their only caller — the webview — was sunset alongside them.
|
||||
*/
|
||||
import * as vscode from "vscode";
|
||||
import type { ProposalController } from "./proposalController";
|
||||
import { diffToBlockHunks } from "./trackChangesModel";
|
||||
import { buildFingerprint } from "./anchorer";
|
||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||
|
||||
/**
|
||||
* F11: a host edit turn (selection/document text + instruction → rewrite).
|
||||
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
|
||||
* the arg is optional so existing test stubs that ignore it stay valid.
|
||||
*/
|
||||
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
|
||||
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
|
||||
export type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
|
||||
|
||||
export class EditFlow implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
/**
|
||||
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
|
||||
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
|
||||
*/
|
||||
private editTurn: EditTurn = async (instruction, text, opts) => {
|
||||
const { runEditTurn } = await import("./liveTurn");
|
||||
return runEditTurn(instruction, text, opts);
|
||||
};
|
||||
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
|
||||
private turnSeq = 0;
|
||||
private nextTurnSeq(): number {
|
||||
return ++this.turnSeq;
|
||||
}
|
||||
|
||||
constructor(private readonly proposals: ProposalController) {}
|
||||
|
||||
/**
|
||||
* F11/F12 (INV-35/39): run one host edit turn and record the result as F4
|
||||
* proposal(s) — a SELECTION yields one single-range proposal over the resolved
|
||||
* block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per
|
||||
* changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged
|
||||
* `granularity:"block"` so accept reconciles attribution per word (INV-40).
|
||||
* Never mutates the document (INV-10). Returns the created proposal ids.
|
||||
*/
|
||||
async runEditAndPropose(
|
||||
document: vscode.TextDocument,
|
||||
target: EditTarget,
|
||||
instruction: string,
|
||||
opts?: RunEditTurnOptions,
|
||||
): Promise<string[]> {
|
||||
const full = document.getText();
|
||||
// One turnId per gesture — the document case's N hunk-proposals all share it,
|
||||
// so a single rewrite groups as one agent turn (parity with editSelection).
|
||||
const turnId = `turn-${this.nextTurnSeq()}`;
|
||||
const provenance = (turn: EditTurnResult) =>
|
||||
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
|
||||
if (target.kind === "range") {
|
||||
const selected = full.slice(target.start, target.end);
|
||||
const turn = await this.editTurn(instruction, selected, opts);
|
||||
if (turn.replacement === "" || turn.replacement === selected) return [];
|
||||
const fp = buildFingerprint(full, { start: target.start, end: target.end });
|
||||
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
|
||||
return id ? [id] : [];
|
||||
}
|
||||
const turn = await this.editTurn(instruction, full, opts);
|
||||
const ids: string[] = [];
|
||||
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
|
||||
// granularity — one proposal per changed block (the unit a human reviews) —
|
||||
// not per word. Each is tagged `granularity:"block"` so accept reconciles
|
||||
// attribution per word inside the block (INV-40).
|
||||
for (const h of diffToBlockHunks(full, turn.replacement)) {
|
||||
const fp = buildFingerprint(full, { start: h.start, end: h.end });
|
||||
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), {
|
||||
turnId,
|
||||
instruction,
|
||||
granularity: "block",
|
||||
});
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ---- test seam (§6.4) ----
|
||||
/** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */
|
||||
setEditTurnForTest(fn: EditTurn): void {
|
||||
this.editTurn = fn;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 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). Phase 2 decorates ALL committed changes-since-baseline by author
|
||||
* (human green / Claude blue + struck hints for deletions), plus pending proposals;
|
||||
* a pinned baseline suppresses committed marks entirely (pin→clean, INV-48).
|
||||
*/
|
||||
import * as vscode from "vscode";
|
||||
import type { ProposalController } from "./proposalController";
|
||||
import type { DiffViewController } from "./diffViewController";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
||||
|
||||
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly insDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = {
|
||||
human: vscode.window.createTextEditorDecorationType({
|
||||
backgroundColor: "rgba(63,185,80,0.14)", borderColor: "#3fb950",
|
||||
border: "0 0 2px 0", borderStyle: "solid",
|
||||
}),
|
||||
claude: vscode.window.createTextEditorDecorationType({
|
||||
backgroundColor: "rgba(88,166,255,0.15)", borderColor: "#58a6ff",
|
||||
border: "0 0 2px 0", borderStyle: "solid",
|
||||
}),
|
||||
};
|
||||
private readonly delDeco: Record<"human" | "claude" | "none", vscode.TextEditorDecorationType> = {
|
||||
human: vscode.window.createTextEditorDecorationType({ after: { color: "#f85149" } }),
|
||||
claude: vscode.window.createTextEditorDecorationType({ after: { color: "#bc8cff" } }),
|
||||
none: vscode.window.createTextEditorDecorationType({ after: { color: new vscode.ThemeColor("descriptionForeground") } }),
|
||||
};
|
||||
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>>();
|
||||
/** Debounce timers for committed-change re-render (one per URI, 50 ms). */
|
||||
private readonly pendingRender = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
constructor(
|
||||
private readonly proposals: ProposalController,
|
||||
private readonly diffView: DiffViewController,
|
||||
private readonly attribution: AttributionController,
|
||||
private readonly registry: CoeditingRegistry,
|
||||
) {
|
||||
this.disposables.push(
|
||||
this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter,
|
||||
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
|
||||
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
|
||||
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
|
||||
// Refresh committed decorations when the baseline changes (establish / pin / head-refresh).
|
||||
this.diffView.onDidChangeBaseline(({ uri }) => {
|
||||
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
|
||||
if (ed) this.renderEditor(ed);
|
||||
}),
|
||||
// Refresh committed decorations (debounced) when the active doc changes — attribution spans shift.
|
||||
vscode.workspace.onDidChangeTextDocument((e) => {
|
||||
const ed = vscode.window.activeTextEditor;
|
||||
if (ed && e.document === ed.document) this.scheduleRender(ed);
|
||||
}),
|
||||
// INV-10: re-render (and refresh CodeLens) on every gate change — exit clears
|
||||
// decorations/CodeLens via renderEditor's own gate check, re-entry redraws.
|
||||
this.registry.onDidChange(({ uri }) => {
|
||||
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
|
||||
if (ed) this.renderEditor(ed);
|
||||
this.lensEmitter.fire();
|
||||
}),
|
||||
// the four QuickPick-backed menu commands
|
||||
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
|
||||
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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),
|
||||
);
|
||||
}
|
||||
|
||||
/** Debounce a committed-change re-render for `editor` (50 ms — lets rapid typing settle). */
|
||||
private scheduleRender(editor: vscode.TextEditor): void {
|
||||
const uri = editor.document.uri.toString();
|
||||
const prev = this.pendingRender.get(uri);
|
||||
if (prev !== undefined) clearTimeout(prev);
|
||||
this.pendingRender.set(
|
||||
uri,
|
||||
setTimeout(() => {
|
||||
this.pendingRender.delete(uri);
|
||||
this.renderEditor(editor);
|
||||
}, 50),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
// INV-10: a doc that exited coediting between the schedule and this tick gets
|
||||
// no optimistic-apply/decorations/CodeLens.
|
||||
if (!this.registry.isCoediting(doc.uri)) return;
|
||||
const key = this.proposals.keyFor(doc);
|
||||
for (const v of this.proposals.listProposals(doc)) {
|
||||
if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) {
|
||||
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;
|
||||
const clearAll = () => {
|
||||
for (const a of ["human", "claude"] as const) editor.setDecorations(this.insDeco[a], []);
|
||||
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []);
|
||||
};
|
||||
if (doc.languageId !== "markdown") return clearAll();
|
||||
// INV-10: a non-entered doc gets no decorations at all.
|
||||
if (!this.registry.isCoediting(doc.uri)) return clearAll();
|
||||
const key = this.proposals.keyFor(doc);
|
||||
const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] };
|
||||
const del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]> = { human: [], claude: [], none: [] };
|
||||
// Pending proposals — always Claude (INV: proposals are agent-authored).
|
||||
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 i of plan.insertions) ins.claude.push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
|
||||
for (const d of plan.deletions) {
|
||||
del.claude.push({
|
||||
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
|
||||
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
|
||||
});
|
||||
}
|
||||
}
|
||||
// Committed changes-since-baseline are added by decorateCommitted (pushes into ins/del[author]).
|
||||
this.decorateCommitted(editor, ins, del);
|
||||
for (const a of ["human", "claude"] as const) editor.setDecorations(this.insDeco[a], ins[a]);
|
||||
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], del[a]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorates committed (non-proposal) changes-since-baseline by author (Task 7 / reverse INV-32).
|
||||
* Diffs current buffer against the F6 baseline; attributes each changed run to its author;
|
||||
* skips any hunk overlapping an applied pending proposal (the proposal loop owns those).
|
||||
* pin→clean: returns without marking when baseline.reason === "pinned".
|
||||
* Adjacency heuristic: a deletion in a hunk that has insertions inherits the hunk author;
|
||||
* a standalone deletion (hunk with no insertions) renders neutral ("none") — matching the
|
||||
* preview's cw-del-none treatment.
|
||||
*/
|
||||
private decorateCommitted(
|
||||
editor: vscode.TextEditor,
|
||||
ins: Record<"human" | "claude", vscode.Range[]>,
|
||||
del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]>,
|
||||
): void {
|
||||
const doc = editor.document;
|
||||
const baseline = this.diffView.getBaseline(doc.uri.toString());
|
||||
// No baseline yet, or baseline was pinned (pin→clean: no committed marks, INV-48).
|
||||
if (!baseline || baseline.reason === "pinned") return;
|
||||
const current = doc.getText();
|
||||
// Nothing changed — short-circuit before the diff.
|
||||
if (current === baseline.text) return;
|
||||
const spans = this.attribution.spansFor(doc);
|
||||
const key = this.proposals.keyFor(doc);
|
||||
// Build the set of current-buffer ranges owned by applied pending proposals so we
|
||||
// can skip hunks that fall inside them — the proposal loop already decorates those.
|
||||
const appliedRanges: { start: number; end: number }[] = [];
|
||||
for (const v of this.proposals.listProposals(doc)) {
|
||||
if (v.anchorStart !== null && v.original !== undefined && this.proposals.isApplied(key, v.id)) {
|
||||
appliedRanges.push({ start: v.anchorStart, end: v.anchorStart + v.replacement.length });
|
||||
}
|
||||
}
|
||||
// wordEditHunks(current, baseline.text): start/end are in current coords; replacement is baseline text.
|
||||
const hunks = wordEditHunks(current, baseline.text);
|
||||
for (const h of hunks) {
|
||||
// Skip hunks whose current-buffer range overlaps an applied pending proposal.
|
||||
// The skip is whole-hunk and therefore conservative: a committed edit word-merged
|
||||
// into a proposal's hunk is skipped entirely — rare at word granularity.
|
||||
if (appliedRanges.some((r) => h.start < r.end && h.end > r.start)) continue;
|
||||
const author = authorAt(h.start, spans) ?? "human";
|
||||
// h.replacement = baseline text for this span; current.slice(h.start, h.end) = applied text.
|
||||
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
||||
for (const i of plan.insertions) {
|
||||
ins[author].push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
|
||||
}
|
||||
// Adjacency heuristic: a deletion paired with an insertion in THIS hunk inherits the
|
||||
// insertion author; a standalone deletion (no insertion in the hunk) is neutral.
|
||||
const delAuthor = plan.insertions.length > 0 ? author : "none";
|
||||
for (const d of plan.deletions) {
|
||||
del[delAuthor].push({
|
||||
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
|
||||
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CodeLensProvider (Task 8, PUC-2 wording): a `✓ Keep` / `✗ Reject` pair above
|
||||
* each applied block, plus — when ≥2 proposals are pending on the doc — a
|
||||
* top-of-file `✓ Keep all (N)` / `✗ Reject all` pair routed directly at the
|
||||
* batch commands (no QuickPick detour for the common "review is done" case).
|
||||
*/
|
||||
provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
|
||||
if (document.languageId !== "markdown") return [];
|
||||
if (!this.registry.isCoediting(document.uri)) return [];
|
||||
const key = this.proposals.keyFor(document);
|
||||
// #70: an interior tweak orphans the exact anchor (anchorStart null) but the
|
||||
// proposal stays fully decidable — Keep by id, Reject via the tracked span —
|
||||
// so the lens pair anchors at the tracked span instead of vanishing (which
|
||||
// made the fixed reject path unreachable from the primary gesture surface).
|
||||
const applied: { id: string; at: number }[] = [];
|
||||
for (const v of this.proposals.listProposals(document)) {
|
||||
if (!this.proposals.isApplied(key, v.id)) continue;
|
||||
const at = v.anchorStart ?? this.proposals.trackedSpan(key, v.id)?.start;
|
||||
if (at !== undefined) applied.push({ id: v.id, at });
|
||||
}
|
||||
const lenses: vscode.CodeLens[] = [];
|
||||
if (applied.length >= 2) {
|
||||
const top = new vscode.Range(0, 0, 0, 0);
|
||||
lenses.push(
|
||||
new vscode.CodeLens(top, { title: `✓ Keep all (${applied.length})`, command: "cowriting.acceptAllProposals" }),
|
||||
new vscode.CodeLens(top, { title: "✗ Reject all", command: "cowriting.rejectAllProposals" }),
|
||||
);
|
||||
}
|
||||
for (const v of applied) {
|
||||
const pos = document.positionAt(v.at);
|
||||
const line = new vscode.Range(pos.line, 0, pos.line, 0);
|
||||
lenses.push(
|
||||
new vscode.CodeLens(line, { title: "✓ Keep", 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") {
|
||||
// The batch commands own the applied/skipped reporting (#70: a silent:true
|
||||
// batch with a discarded tally re-creates the silent-skip failure mode).
|
||||
if (all) await vscode.commands.executeCommand("cowriting.acceptAllProposals");
|
||||
else await this.proposals.finalizeInPlace(key, id);
|
||||
} else {
|
||||
if (all) await vscode.commands.executeCommand("cowriting.rejectAllProposals");
|
||||
else await this.proposals.revertInPlace(key, id);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const t of this.pendingApply.values()) clearTimeout(t);
|
||||
this.pendingApply.clear();
|
||||
for (const t of this.pendingRender.values()) clearTimeout(t);
|
||||
this.pendingRender.clear();
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
+301
-130
@@ -10,9 +10,15 @@ import { BaselineStore } from "./baselineStore";
|
||||
import { GlobalSidecarStore } from "./globalSidecarStore";
|
||||
import { SidecarRouter } from "./sidecarRouter";
|
||||
import { DiffViewController } from "./diffViewController";
|
||||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||||
import { GitBaselineAdapter } from "./gitBaseline";
|
||||
import { EditFlow } from "./editFlow";
|
||||
import { LiveProgressUi } from "./liveProgressUi";
|
||||
import { isAuthorable, selectionRejection } from "./workspacePath";
|
||||
import { EditorProposalController } from "./editorProposalController";
|
||||
import { isAuthorable, routeEdit } from "./workspacePath";
|
||||
import { CoeditingRegistry } from "./coeditingRegistry";
|
||||
import { ScmSurfaceController } from "./scmSurface";
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import { cowritingMarkdownItPlugin, type AnnotationInputs } from "./previewAnnotations";
|
||||
|
||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||
|
||||
@@ -22,9 +28,19 @@ export interface CowritingApi {
|
||||
proposalController: ProposalController;
|
||||
versionGuard: VersionGuard;
|
||||
diffViewController: DiffViewController;
|
||||
trackChangesPreviewController: TrackChangesPreviewController;
|
||||
editFlow: EditFlow;
|
||||
sidecarRouter: SidecarRouter;
|
||||
liveProgressUi: LiveProgressUi;
|
||||
editorProposalController: EditorProposalController;
|
||||
coeditingRegistry: CoeditingRegistry;
|
||||
scmSurfaceController: ScmSurfaceController;
|
||||
/** Task 7 test seam: the REAL production `inputsFor`, so E2E exercises the
|
||||
* actual registry/diffView/attribution/proposals/config wiring (not a
|
||||
* hand-rolled reimplementation of it). */
|
||||
previewAnnotationHost: { inputsFor(env: unknown): AnnotationInputs | undefined };
|
||||
/** Task 7 (D3/D21): the markdown-it plugin factory VS Code's built-in preview
|
||||
* loads via `markdown.markdownItPlugins` (package.json). */
|
||||
extendMarkdownIt: (md: unknown) => unknown;
|
||||
}
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||
@@ -57,19 +73,66 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
}),
|
||||
);
|
||||
|
||||
// --- F6: baseline data layer (Features #17 + #19) — workspace-INDEPENDENT ---
|
||||
// The two-pane vscode.diff VIEW was removed in #34 (F10's rendered preview is
|
||||
// the single review surface); only the baseline store survives, consumed by
|
||||
// F7/F10. It tracks a baseline for ANY diffable doc (file or untitled), so it
|
||||
// is constructed regardless of whether a folder is open. Baseline lives in VS
|
||||
// Code's per-extension GLOBAL storage (always present), never the repo
|
||||
// (INV-19). The machine-landing advance is wired below. The controller
|
||||
// self-wires baseline capture on open.
|
||||
// --- PUC-7 (native-surfaces migration): the INV-10 opt-in gate ---
|
||||
// Every later native surface (SCM/QuickDiff, comments, preview annotations,
|
||||
// edit commands, decorations) checks isCoediting before attaching. Persisted
|
||||
// in workspaceState so a reload restores the set.
|
||||
const coeditingRegistry = new CoeditingRegistry(context.workspaceState);
|
||||
context.subscriptions.push(coeditingRegistry);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.coeditDocument", () => {
|
||||
const ed = vscode.window.activeTextEditor;
|
||||
if (!ed || ed.document.languageId !== "markdown" || !isAuthorable(ed.document.uri.scheme)) {
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to coedit it.");
|
||||
return;
|
||||
}
|
||||
coeditingRegistry.enter(ed.document.uri);
|
||||
coeditingRegistry.syncContext(ed);
|
||||
}),
|
||||
vscode.commands.registerCommand("cowriting.stopCoediting", () => {
|
||||
const ed = vscode.window.activeTextEditor;
|
||||
if (!ed) return;
|
||||
coeditingRegistry.exit(ed.document.uri);
|
||||
coeditingRegistry.syncContext(ed);
|
||||
}),
|
||||
vscode.window.onDidChangeActiveTextEditor((ed) => coeditingRegistry.syncContext(ed)),
|
||||
);
|
||||
coeditingRegistry.syncContext(vscode.window.activeTextEditor);
|
||||
|
||||
// --- Baseline router (native-surfaces migration, spec §6.4, INV-7) — workspace-
|
||||
// INDEPENDENT --- The two-pane vscode.diff VIEW was removed in #34 (F10's
|
||||
// rendered preview is the single review surface); only the baseline data
|
||||
// layer survives, consumed by F7/F10. It tracks a baseline for ANY diffable
|
||||
// doc (file or untitled), so it is constructed regardless of whether a folder
|
||||
// is open. A SNAPSHOT baseline lives in VS Code's per-extension GLOBAL
|
||||
// storage (always present), never the repo (INV-19); a git-tracked doc's HEAD
|
||||
// baseline is never persisted (INV-7). Baselines are established only for
|
||||
// documents the CoeditingRegistry has entered — see the registry wiring
|
||||
// inside DiffViewController's constructor.
|
||||
const gitBaseline = new GitBaselineAdapter();
|
||||
context.subscriptions.push(gitBaseline);
|
||||
const baselineStorageDir = context.globalStorageUri?.fsPath;
|
||||
const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null;
|
||||
const diffViewController = new DiffViewController(baselineStore);
|
||||
const diffViewController = new DiffViewController(baselineStore, gitBaseline, coeditingRegistry);
|
||||
context.subscriptions.push(diffViewController);
|
||||
|
||||
// Task 1 hook: expose the resolved baseline mode ("head"/"snapshot") to the
|
||||
// registry's context-key sync, and re-sync `cowriting.baselineMode` whenever
|
||||
// a baseline is (re)captured (entry, head-refresh, pin) — the "Mark Changes
|
||||
// as Reviewed" menu entry (snapshot-only, Task 3) is gated on that key.
|
||||
coeditingRegistry.baselineModeOf = (uri) => diffViewController.modeOf(uri.toString());
|
||||
context.subscriptions.push(
|
||||
diffViewController.onDidChangeBaseline(() => coeditingRegistry.syncContext(vscode.window.activeTextEditor)),
|
||||
);
|
||||
|
||||
// --- Task 3: the native diff surface (spec §6.4/§5, INV-13) — QuickDiff
|
||||
// gutter bars, the cowriting-baseline: content provider, "Review Changes",
|
||||
// and the status-bar change count. Constructed after diffViewController
|
||||
// (consumes getBaseline/modeOf/onDidChangeBaseline) and the registry
|
||||
// (consumes isCoediting/onDidChange), gated on INV-10. ---
|
||||
const scmSurfaceController = new ScmSurfaceController(coeditingRegistry, diffViewController);
|
||||
context.subscriptions.push(scmSurfaceController);
|
||||
|
||||
// F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage
|
||||
// home as the F6 baseline, keyed by sha256(uri) (INV-19/24). Constructed
|
||||
// workspace-independently; the router falls back to it for any non-in-folder
|
||||
@@ -90,34 +153,51 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
// F5 (INV-16): one shared guard — newer-major sidecars are read-only, one
|
||||
// warning per doc across the three co-owning controllers.
|
||||
const versionGuard = new VersionGuard(sidecarRouter);
|
||||
const threadController = new ThreadController(sidecarRouter, root, versionGuard);
|
||||
context.subscriptions.push(threadController);
|
||||
|
||||
// --- F3: live attribution (Feature #6) ---
|
||||
const attributionController = new AttributionController(sidecarRouter, root, versionGuard);
|
||||
const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry);
|
||||
context.subscriptions.push(attributionController);
|
||||
|
||||
// --- F4: propose/accept (Feature #12) — constructed before the preview so F10
|
||||
// can route ✓/✗ through it ---
|
||||
const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard);
|
||||
// --- F4: propose/accept (Feature #12) ---
|
||||
const proposalController = new ProposalController(
|
||||
sidecarRouter,
|
||||
attributionController,
|
||||
root,
|
||||
versionGuard,
|
||||
coeditingRegistry,
|
||||
);
|
||||
context.subscriptions.push(proposalController);
|
||||
|
||||
// --- F7/F10: the review preview is the single interactive review surface ---
|
||||
// Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline,
|
||||
// INV-20). Constructed AFTER attribution (reads F3 spans) and proposals (routes
|
||||
// F4 accept/reject from the webview ✓/✗).
|
||||
const trackChangesPreviewController = new TrackChangesPreviewController(
|
||||
diffViewController,
|
||||
context.extensionUri,
|
||||
attributionController,
|
||||
// --- F11/F12 (native-surfaces migration, spec §6.10): the edit flow — the
|
||||
// `cowriting.editDocument` command, the instruction-prompt/progress-wrapped
|
||||
// "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut
|
||||
// (`runEditAndPropose`, INV-39/40). Constructed BEFORE the thread controller,
|
||||
// which consumes it too (Task 6: the comment-loop's "make this edit" offer
|
||||
// calls runEditAndPropose). ---
|
||||
const editFlow = new EditFlow(proposalController);
|
||||
context.subscriptions.push(editFlow);
|
||||
|
||||
// --- F2/F10 (Task 6, D19/D10/D8): comments-first ask + the comment→reply→
|
||||
// offer→proposal loop. Consumes editFlow (offer → pending proposals) and
|
||||
// liveProgressUi (the shared "asking Claude…" notification + output channel). ---
|
||||
const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry, editFlow, liveProgressUi);
|
||||
context.subscriptions.push(threadController);
|
||||
|
||||
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
|
||||
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
|
||||
const editorProposalController = new EditorProposalController(
|
||||
proposalController,
|
||||
liveProgressUi,
|
||||
diffViewController,
|
||||
attributionController,
|
||||
coeditingRegistry,
|
||||
);
|
||||
context.subscriptions.push(trackChangesPreviewController);
|
||||
context.subscriptions.push(editorProposalController);
|
||||
|
||||
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
|
||||
// (also reachable from the preview toolbar's "Accept all" button). Reuses the
|
||||
// batched F4 seam + reports applied-vs-skipped.
|
||||
// (also reachable from the top-of-file "Keep all (N)" CodeLens, Task 8 PUC-2).
|
||||
// Reuses the batched F4 seam + reports applied-vs-skipped, routing directly to
|
||||
// ProposalController (native-surfaces migration: no preview-controller
|
||||
// indirection).
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => {
|
||||
const doc = vscode.window.activeTextEditor?.document;
|
||||
@@ -125,17 +205,40 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals.");
|
||||
return;
|
||||
}
|
||||
await trackChangesPreviewController.acceptAll(doc);
|
||||
const { applied, skipped } = await proposalController.acceptAllProposals(doc);
|
||||
if (applied === 0 && skipped === 0) return;
|
||||
const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : "";
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// --- F6 machine-landing wiring — now for ANY authorable doc ---
|
||||
// The seam's single machine-landing signal advances the F6 baseline (INV-18);
|
||||
// the seam can now fire on out-of-folder files too, so wire it unconditionally.
|
||||
// #64 (INV-53): reject every pending proposal on the active doc in one gesture
|
||||
// (native-surfaces migration: routes directly to ProposalController).
|
||||
context.subscriptions.push(
|
||||
attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)),
|
||||
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;
|
||||
}
|
||||
const { reverted, skipped } = await proposalController.rejectAll(doc);
|
||||
if (reverted === 0 && skipped === 0) return;
|
||||
const skipNote = skipped > 0 ? `, ${skipped} skipped (applied text not locatable — undo your edits or Keep)` : "";
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}${skipNote}.`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// NOTE (native-surfaces migration, INV-7/INV-18 retirement): the shipped
|
||||
// machine-landing baseline advance (#48) — wiring
|
||||
// attributionController.onDidApplyAgentEdit → diffViewController.advance —
|
||||
// was removed here. A landed Claude edit now stays a visible change-since-
|
||||
// baseline until the file is committed (head mode) or reviewed via
|
||||
// "Mark Changes as Reviewed" (snapshot mode).
|
||||
|
||||
// One SHARED sidecar watcher for both controllers; self-writes are suppressed
|
||||
// centrally in the repo store (only repo `.threads/` sidecars are watched —
|
||||
// global artifacts live outside the workspace). Harmless when no folder is open.
|
||||
@@ -197,111 +300,116 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
),
|
||||
);
|
||||
|
||||
// F3 SLICE-5 → F4 SLICE-4: the live turn — selection + instruction → one
|
||||
// claude-code SDK turn (liveTurn.ts, INV-8) → a PENDING PROPOSAL (F4,
|
||||
// INV-10); the applyAgentEdit seam (INV-9) now fires only on accept.
|
||||
// Task 6 (D19): the comments-first ask replaces the old inline turn
|
||||
// plumbing here — the ask now IS a comment. threadController.askClaude()
|
||||
// opens a focused comment box on the current selection (or, with no
|
||||
// selection, a top-anchored whole-document thread); the comment→reply→
|
||||
// offer→proposal loop (ThreadController.respondInThread/makeThreadEdit)
|
||||
// takes it from there. Kept as its own command (rather than folded into
|
||||
// cowriting.edit) for the host E2E harness and the internal seams.
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.editSelection", async () => {
|
||||
await threadController.askClaude();
|
||||
}),
|
||||
);
|
||||
|
||||
// Code-review fix (Finding 1, session native-surfaces-exec): `cowriting.editDocument`
|
||||
// used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompted via
|
||||
// `askEditInstruction` — a rejecting stub after Task 6 sunset the input webview
|
||||
// (spec §6.10; both `askClaude`/`askEditInstruction` were deleted from EditFlow in
|
||||
// Task 8 once their only caller, the review webview, died too). Reached with no
|
||||
// selection (the common case for `cowriting.edit`), that was a SILENT dead end: the
|
||||
// reject fired before EditFlow.askClaude's own try/catch and was `void`'d, so no
|
||||
// toast, no thread, nothing. This command now resolves
|
||||
// its target document exactly as before (a tab's clicked URI, falling back to the
|
||||
// active editor), focuses it, and hands off to the SAME comments-first ask as
|
||||
// `cowriting.editSelection` — `ThreadController.askClaude()` top-anchors a
|
||||
// whole-document ask when the (now-focused) editor's selection is empty (D19). Kept
|
||||
// registered + hidden from the palette for the host E2E harness and #42's
|
||||
// tab-targeting behavior; `EditFlow.runEditAndPropose` (untouched) stays the E2E seam
|
||||
// for driving a turn without the native comment UI — see f12Reach.test.ts /
|
||||
// f11Toolbar.test.ts / f12Accept.test.ts / f12Review.test.ts.
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => {
|
||||
const doc = uri
|
||||
? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ??
|
||||
(await vscode.workspace.openTextDocument(uri))
|
||||
: vscode.window.activeTextEditor?.document;
|
||||
if (!doc || doc.languageId !== "markdown") {
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
|
||||
return;
|
||||
}
|
||||
if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) {
|
||||
await vscode.window.showTextDocument(doc);
|
||||
}
|
||||
await threadController.askClaude();
|
||||
}),
|
||||
);
|
||||
|
||||
// The single user-facing "Ask Claude to Edit" gesture (one command, one
|
||||
// keybinding, one menu entry). It routes to the selection flow (editSelection)
|
||||
// or the whole-document flow (editDocument) by selection/context — see
|
||||
// routeEdit. Both underlying commands now end at ThreadController.askClaude()
|
||||
// (comments-first ask, D19); they stay registered for the host E2E harness and
|
||||
// the internal seams, but are hidden from the palette.
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
// F8: authoring works on any file: or untitled: doc (the router decides
|
||||
// where its artifact is stored). Each failure still names its real reason
|
||||
// (no editor / no selection / a non-{file,untitled} read-only view) — not
|
||||
// always "select some text" (#24's per-condition messaging).
|
||||
const reason = selectionRejection({
|
||||
hasEditor: !!editor,
|
||||
const route = routeEdit({
|
||||
hasUri: !!uri,
|
||||
uriMatchesActiveEditor: !!uri && editor?.document.uri.toString() === uri.toString(),
|
||||
hasActiveEditor: !!editor,
|
||||
selectionEmpty: editor?.selection.isEmpty ?? true,
|
||||
scheme: editor?.document.uri.scheme ?? "",
|
||||
});
|
||||
if (reason) {
|
||||
void vscode.window.showWarningMessage(reason);
|
||||
return;
|
||||
}
|
||||
if (!editor) return; // unreachable once reason is null, but narrows the type
|
||||
const instruction = await vscode.window.showInputBox({
|
||||
prompt: "What should Claude do with the selection?",
|
||||
placeHolder: "e.g. tighten this paragraph",
|
||||
});
|
||||
if (!instruction) return;
|
||||
if (editor.selection.isEmpty) {
|
||||
void vscode.window.showWarningMessage("Cowriting: select some text to send to Claude first.");
|
||||
return;
|
||||
}
|
||||
const document = editor.document;
|
||||
const selection = editor.selection;
|
||||
const selectedText = document.getText(selection);
|
||||
// Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits
|
||||
// can't skew it — the proposal renders wherever the target re-resolves.
|
||||
const fp = buildFingerprint(document.getText(), {
|
||||
start: document.offsetAt(selection.start),
|
||||
end: document.offsetAt(selection.end),
|
||||
});
|
||||
const turnId = `turn-${Date.now().toString(36)}`;
|
||||
try {
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: "Cowriting: asking Claude…",
|
||||
cancellable: true,
|
||||
},
|
||||
async (progress, token) => {
|
||||
const { runEditTurn } = await import("./liveTurn");
|
||||
const ui = liveProgressUi.begin(instruction, progress, token);
|
||||
let turn;
|
||||
try {
|
||||
turn = await runEditTurn(instruction, selectedText, {
|
||||
onProgress: ui.onProgress,
|
||||
signal: ui.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
// #60 (INV-47): a user cancel surfaces as "cancelled", not a failure.
|
||||
if (token.isCancellationRequested) {
|
||||
void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled.");
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (turn.replacement === "") {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (turn.replacement === selectedText) {
|
||||
void vscode.window.showInformationMessage(
|
||||
"Cowriting: Claude proposed no change to the selection.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// F4 (INV-10): the turn ends in a PROPOSAL, not a buffer mutation.
|
||||
// The seam now fires only on accept (ProposalController, INV-9).
|
||||
const id = await proposalController.propose(
|
||||
document,
|
||||
fp,
|
||||
turn.replacement,
|
||||
{
|
||||
kind: "agent",
|
||||
id: "claude",
|
||||
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId },
|
||||
},
|
||||
{ turnId, instruction },
|
||||
);
|
||||
if (id) {
|
||||
void vscode.window.showInformationMessage(
|
||||
"Cowriting: Claude proposed an edit — review the diff at the highlighted range (✓ accept / ✗ reject).",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
|
||||
if (route === "selection") {
|
||||
await vscode.commands.executeCommand("cowriting.editSelection");
|
||||
} else {
|
||||
await vscode.commands.executeCommand("cowriting.editDocument", uri);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Task 8 (native-surfaces migration, spec §6.10): the minimal gateway replacing
|
||||
// the sunset review-webview's #41 right-click entries. Resolves the clicked
|
||||
// doc (or falls back to the active editor, mirroring editDocument/#41), enters
|
||||
// coediting if not already (so a fresh doc gets a baseline to review against —
|
||||
// no-op if already entered or not authorable), then hands off to VS Code's
|
||||
// OWN "Open Preview to the Side" — the built-in preview IS the review surface
|
||||
// now (Task 7's annotations render inside it).
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.openReviewPreview", async (uri?: vscode.Uri) => {
|
||||
const doc = uri
|
||||
? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ??
|
||||
(await vscode.workspace.openTextDocument(uri))
|
||||
: vscode.window.activeTextEditor?.document;
|
||||
if (!doc || doc.languageId !== "markdown") {
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to review it.");
|
||||
return;
|
||||
}
|
||||
if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) {
|
||||
await vscode.window.showTextDocument(doc);
|
||||
}
|
||||
if (!coeditingRegistry.isCoediting(doc.uri) && isAuthorable(doc.uri.scheme)) {
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
}
|
||||
await vscode.commands.executeCommand("markdown.showPreviewToSide", doc.uri);
|
||||
}),
|
||||
);
|
||||
|
||||
// Render threads + attributions for already-open editors, and on future opens.
|
||||
// INV-10: gated on the registry too — belt-and-suspenders with the per-controller
|
||||
// gates (renderAll/loadAll already early-return), but keeps the intent explicit
|
||||
// at the call site and avoids the no-op calls for a non-entered doc.
|
||||
const renderIfOpen = (doc: vscode.TextDocument) => {
|
||||
if (isAuthorable(doc.uri.scheme)) {
|
||||
if (isAuthorable(doc.uri.scheme) && coeditingRegistry.isCoediting(doc.uri)) {
|
||||
// Finding 3 (final whole-branch review): DiffViewController only
|
||||
// establishes a baseline on the registry's enter TRANSITION and, at
|
||||
// startup, for documents already open. A doc that is a PERSISTED
|
||||
// coediting member but wasn't open yet at either of those moments (e.g.
|
||||
// lazily restored after a window reload) reaches this handler with no
|
||||
// baseline/mode — establish it now so QuickDiff/Review-Changes/Mark-
|
||||
// Reviewed never see a silent empty-baseline gap.
|
||||
if (diffViewController.modeOf(doc.uri.toString()) === undefined) void diffViewController.establish(doc);
|
||||
threadController.renderAll(doc);
|
||||
attributionController.loadAll(doc);
|
||||
proposalController.renderAll(doc);
|
||||
@@ -310,15 +418,78 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
||||
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
|
||||
|
||||
// --- Task 7 (D3/D21, PUC-3): annotations in the BUILT-IN Markdown preview ---
|
||||
// `env.currentDocument` (VS Code's markdown-it render env) is an OBSERVED
|
||||
// preview-engine behavior, not a documented contract, so `lastCoeditedUri`
|
||||
// is the fallback that keeps single-doc correctness when it's absent — the
|
||||
// last editor that WAS coediting when it lost focus (e.g. focus moved to the
|
||||
// preview pane itself) stays the annotation target until a different
|
||||
// coedited doc becomes active.
|
||||
let lastCoeditedUri: string | undefined;
|
||||
const trackLastCoedited = (ed: vscode.TextEditor | undefined) => {
|
||||
if (ed && coeditingRegistry.isCoediting(ed.document.uri)) lastCoeditedUri = ed.document.uri.toString();
|
||||
};
|
||||
trackLastCoedited(vscode.window.activeTextEditor);
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveTextEditor(trackLastCoedited),
|
||||
// The active editor doesn't itself change when `cowriting.coeditDocument`
|
||||
// enters IT into coediting — re-check on every gate change too, so the
|
||||
// very first render after entering already has an annotation target.
|
||||
coeditingRegistry.onDidChange(() => trackLastCoedited(vscode.window.activeTextEditor)),
|
||||
);
|
||||
|
||||
const previewAnnotationHost = {
|
||||
inputsFor(env: unknown): AnnotationInputs | undefined {
|
||||
const envUri = (env as { currentDocument?: { toString(): string } } | undefined)?.currentDocument?.toString();
|
||||
// Finding 1 (final whole-branch review): the `lastCoeditedUri` fallback
|
||||
// is for an ABSENT env only (env.currentDocument didn't resolve) — a
|
||||
// present envUri is authoritative for the doc actually being previewed
|
||||
// and must never fall back to a different (or since-exited) document's
|
||||
// inputs. Either way, the winning key must currently be a coediting
|
||||
// member (INV-10) — an exited doc's stale `lastCoeditedUri` fails this
|
||||
// check too, closing the "preview stays annotated after stopCoediting"
|
||||
// gap.
|
||||
const key = envUri === undefined ? lastCoeditedUri : envUri;
|
||||
if (!key || !coeditingRegistry.list().includes(key)) return undefined;
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
|
||||
if (!doc) return undefined;
|
||||
const baseline = diffViewController.getBaseline(key);
|
||||
return {
|
||||
baselineText: baseline?.text,
|
||||
baselineReason: baseline?.reason,
|
||||
spans: attributionController.spansFor(doc),
|
||||
proposals: proposalController.listProposals(doc),
|
||||
enabled: vscode.workspace.getConfiguration("cowriting").get<boolean>("annotations", true),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Toggle: flips `cowriting.annotations` + refreshes the built-in preview
|
||||
// (VS Code re-invokes extendMarkdownIt's plugin on the next render — no
|
||||
// extension-side re-render call exists beyond the standard refresh command).
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.toggleAnnotations", async () => {
|
||||
const config = vscode.workspace.getConfiguration("cowriting");
|
||||
const current = config.get<boolean>("annotations", true);
|
||||
await config.update("annotations", !current, vscode.ConfigurationTarget.Global);
|
||||
await vscode.commands.executeCommand("markdown.preview.refresh");
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
threadController,
|
||||
attributionController,
|
||||
proposalController,
|
||||
versionGuard,
|
||||
diffViewController,
|
||||
trackChangesPreviewController,
|
||||
editFlow,
|
||||
sidecarRouter,
|
||||
liveProgressUi,
|
||||
editorProposalController,
|
||||
coeditingRegistry,
|
||||
scmSurfaceController,
|
||||
previewAnnotationHost,
|
||||
extendMarkdownIt: (md: unknown) => cowritingMarkdownItPlugin(md as MarkdownIt, previewAnnotationHost),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* GitBaselineAdapter — resolves a document's git-HEAD baseline via the built-in
|
||||
* Git extension API (spec §6.4, INV-7). Spike-verified: Repository.show('HEAD',
|
||||
* path) + state.onDidChange work as assumed; the change event is coarse, so
|
||||
* re-reads key off state.HEAD.commit; a nested repo may need openRepository().
|
||||
*
|
||||
* Hardening (Task 2 E2E, host-verified): for a repo that is NOT a workspace
|
||||
* folder (the common case here — F8/#19 precedent, coediting works on any
|
||||
* file), the git extension's own filesystem watcher does not reliably notice
|
||||
* an out-of-band commit (e.g. a `git commit` run from a terminal, or in the
|
||||
* host E2E) — `repo.state.onDidChange` can simply never fire. We additionally
|
||||
* watch `.git/logs/HEAD` (the reflog VS Code itself also watches for
|
||||
* in-workspace repos) with Node's own `fs.watch` and nudge `repo.status()`,
|
||||
* which reliably re-syncs `state.HEAD` and lets the existing onDidChange path
|
||||
* do the rest. Best-effort: any failure here just falls back to the passive
|
||||
* listener.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
|
||||
interface GitRepository {
|
||||
rootUri: vscode.Uri;
|
||||
state: { HEAD?: { commit?: string }; onDidChange: vscode.Event<void> };
|
||||
show(ref: string, path: string): Promise<string>;
|
||||
status(): Promise<void>;
|
||||
}
|
||||
interface GitAPI {
|
||||
repositories: GitRepository[];
|
||||
getRepository(uri: vscode.Uri): GitRepository | null;
|
||||
openRepository(root: vscode.Uri): Promise<GitRepository | null>;
|
||||
onDidOpenRepository: vscode.Event<GitRepository>;
|
||||
}
|
||||
|
||||
export interface HeadBaseline { text: string; commit: string }
|
||||
|
||||
export class GitBaselineAdapter implements vscode.Disposable {
|
||||
private api: GitAPI | null | undefined; // undefined = not resolved yet; null = unavailable
|
||||
private readonly hooked = new WeakSet<GitRepository>();
|
||||
private readonly lastCommit = new Map<string, string>(); // repo root → HEAD commit
|
||||
private readonly emitter = new vscode.EventEmitter<void>();
|
||||
readonly onDidChangeHead = this.emitter.event;
|
||||
private readonly disposables: vscode.Disposable[] = [this.emitter];
|
||||
|
||||
private async gitApi(): Promise<GitAPI | null> {
|
||||
if (this.api !== undefined) return this.api;
|
||||
try {
|
||||
const ext = vscode.extensions.getExtension("vscode.git");
|
||||
if (!ext) return (this.api = null);
|
||||
const exports = ext.isActive ? ext.exports : await ext.activate();
|
||||
this.api = exports.getAPI(1) as GitAPI;
|
||||
this.disposables.push(this.api.onDidOpenRepository((r) => this.hook(r)));
|
||||
for (const r of this.api.repositories) this.hook(r);
|
||||
} catch {
|
||||
this.api = null;
|
||||
}
|
||||
return this.api;
|
||||
}
|
||||
|
||||
/** HEAD blob text + commit for a tracked file; null when untracked / no repo / no HEAD. */
|
||||
async headFor(uri: vscode.Uri): Promise<HeadBaseline | null> {
|
||||
if (uri.scheme !== "file") return null;
|
||||
const git = await this.gitApi();
|
||||
if (!git) return null;
|
||||
let repo = git.getRepository(uri);
|
||||
if (!repo) repo = await this.discoverNested(git, uri);
|
||||
if (!repo) return null;
|
||||
this.hook(repo);
|
||||
try {
|
||||
const text = await repo.show("HEAD", uri.fsPath);
|
||||
return { text, commit: repo.state.HEAD?.commit ?? "" };
|
||||
} catch {
|
||||
return null; // untracked, ignored, or no commit yet → snapshot mode
|
||||
}
|
||||
}
|
||||
|
||||
/** Spike finding: a nested repo may not be auto-opened — walk up for .git, then openRepository. */
|
||||
private async discoverNested(git: GitAPI, uri: vscode.Uri): Promise<GitRepository | null> {
|
||||
let dir = path.dirname(uri.fsPath);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
if (fs.existsSync(path.join(dir, ".git"))) {
|
||||
await git.openRepository(vscode.Uri.file(dir));
|
||||
return git.getRepository(uri);
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Watch a repo; fire onDidChangeHead only when HEAD's commit actually moves. */
|
||||
private hook(repo: GitRepository): void {
|
||||
if (this.hooked.has(repo)) return;
|
||||
this.hooked.add(repo);
|
||||
this.lastCommit.set(repo.rootUri.toString(), repo.state.HEAD?.commit ?? "");
|
||||
this.disposables.push(
|
||||
repo.state.onDidChange(() => {
|
||||
const key = repo.rootUri.toString();
|
||||
const commit = repo.state.HEAD?.commit ?? "";
|
||||
if (this.lastCommit.get(key) !== commit) {
|
||||
this.lastCommit.set(key, commit);
|
||||
this.emitter.fire();
|
||||
}
|
||||
}),
|
||||
);
|
||||
this.watchReflog(repo);
|
||||
}
|
||||
|
||||
/** Best-effort nudge: watch `.git/logs/HEAD` directly and force `repo.status()`
|
||||
* on any write, so an out-of-band commit is picked up even when the git
|
||||
* extension's own watcher doesn't fire (see the class doc). */
|
||||
private watchReflog(repo: GitRepository): void {
|
||||
try {
|
||||
const reflog = path.join(repo.rootUri.fsPath, ".git", "logs", "HEAD");
|
||||
if (!fs.existsSync(reflog)) return;
|
||||
let pending: ReturnType<typeof setTimeout> | undefined;
|
||||
const watcher = fs.watch(reflog, () => {
|
||||
if (pending) clearTimeout(pending);
|
||||
pending = setTimeout(() => void repo.status().catch(() => {}), 150);
|
||||
});
|
||||
this.disposables.push({
|
||||
dispose: () => {
|
||||
watcher.close();
|
||||
if (pending) clearTimeout(pending);
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// best-effort only — the passive repo.state.onDidChange listener still applies.
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,25 @@ export async function runEditTurn(
|
||||
providerId: "claude-code",
|
||||
modelId,
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
// The claude-code provider spawns the bundled `claude` CLI. On macOS, when
|
||||
// that process is launched from inside VS Code it performs IDE auto-connect,
|
||||
// which shells out to `osascript` (`tell application …`) to identify/activate
|
||||
// the editor — raising the macOS "control other applications" (Apple Events)
|
||||
// permission prompt (#59). We only consume the turn's text result and never
|
||||
// use the IDE connection, so we disable it: `CLAUDE_CODE_AUTO_CONNECT_IDE=0`
|
||||
// hard-returns out of the auto-connect path in the binary, and
|
||||
// `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1` skips the extension-install deep link.
|
||||
// Seeding with the full `process.env` keeps the default inheritance intact
|
||||
// (login under HOME, proxy/CA vars) — we only add the two switches. The
|
||||
// provider forwards `options.env` into the spawned child (verified end to end
|
||||
// through @cline/sdk → ai-sdk-provider-claude-code → @anthropic-ai/claude-agent-sdk).
|
||||
options: {
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_CODE_AUTO_CONNECT_IDE: "0",
|
||||
CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Stream reduced progress snapshots out (INV-44 additive) and wire cancellation
|
||||
|
||||
@@ -97,6 +97,14 @@ export interface Proposal {
|
||||
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
|
||||
*/
|
||||
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 {
|
||||
@@ -252,6 +260,8 @@ export function serializeArtifact(a: Artifact): string {
|
||||
createdAt: p.createdAt,
|
||||
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
|
||||
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
|
||||
...(p.original !== undefined ? { original: p.original } : {}),
|
||||
...(p.granularity !== undefined ? { granularity: p.granularity } : {}),
|
||||
},
|
||||
p,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* previewAnnotations — Task 7 (D3/D21, PUC-3): a pure, vscode-free markdown-it
|
||||
* plugin that annotates the BUILT-IN VS Code Markdown preview with the F10
|
||||
* authorship/change-mark vocabulary (`.cw-ins-claude` / `.cw-ins-human` /
|
||||
* `.cw-del`) — the native-surfaces migration's replacement for the sunset
|
||||
* webview's review body (spec §6.4/§6.10). Reuses the shipped sentinel
|
||||
* discipline (`injectSentinels`/`sentinelsToSpans`, #33/#47-hardened) instead
|
||||
* of inventing a new one: `annotateSource` computes PUA-sentinel-wrapped
|
||||
* markdown SOURCE (never HTML) against the F6/F7 baseline + F3 spans + F4
|
||||
* pending proposals; `cowritingMarkdownItPlugin` wires that transform into the
|
||||
* shared `markdown-it` instance (the SAME instance VS Code's built-in preview
|
||||
* uses via `markdown.markdownItPlugins`) as (a) a core rule — swap `state.src`
|
||||
* before `normalize` — and (b) a full-render wrapper that turns surviving
|
||||
* sentinel pairs into `<span class="cw-…">`.
|
||||
*
|
||||
* Implementation note (resolved drift from the migration plan's Task 7 sketch,
|
||||
* which described (b) as "a text renderer rule"): `sentinelsToSpans` is a
|
||||
* stateful, single-pass walk over one HTML string — installing it as a
|
||||
* PER-TOKEN `renderer.rules.text` hook would reset that state at every text
|
||||
* token boundary and silently break the "survives emphasis/tag boundaries"
|
||||
* invariant (#33 CASE3) for any span whose sentinel-open and sentinel-close
|
||||
* land in DIFFERENT text tokens (e.g. either side of a `**bold**` run — the
|
||||
* exact scenario CASE3 exists to cover). Wrapping `md.renderer.render` instead
|
||||
* runs the walk ONCE over the fully-assembled HTML, exactly like the F9/F10
|
||||
* `colorByAuthor` precedent (`render(injected)` — a whole-block HTML string,
|
||||
* not a token fragment) — this is what actually delivers the stated invariant,
|
||||
* robust to callers that invoke `md.render()` directly or `md.parse()` +
|
||||
* `md.renderer.render()` separately (VS Code's own scroll-sync `data-line`
|
||||
* injection uses the latter).
|
||||
*
|
||||
* `host.inputsFor(env)` is the ONE thin, vscode-touching seam (owned by
|
||||
* `extension.ts`): `env.currentDocument` is an OBSERVED VS Code preview-engine
|
||||
* behavior, not a documented contract, so the host falls back to the last
|
||||
* actively-coedited document when it is absent (see extension.ts's
|
||||
* `lastCoeditedUri`).
|
||||
*
|
||||
* Deliberate choice: the mermaid-fence rendering (the `options.highlight`
|
||||
* override installed below) applies to EVERY markdown preview, coedited or
|
||||
* not — it is not gated on coediting state. Gating it would make a diagram's
|
||||
* rendering flip (mermaid <-> plain fence) the instant a doc enters or exits
|
||||
* coediting, which is a worse surprise than always rendering mermaid diagrams
|
||||
* as diagrams; only the change-tracking OVERLAY on top (`buildMermaidQueue`'s
|
||||
* diff augmentation) is gated on having a baseline to diff against.
|
||||
*/
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import { diffMermaid } from "./mermaidDiff";
|
||||
import {
|
||||
diffBlocks,
|
||||
injectSentinels,
|
||||
landedTextOf,
|
||||
MERMAID_LEGEND,
|
||||
mermaidFenceBody,
|
||||
sentinelsToSpans,
|
||||
splitBlocksWithRanges,
|
||||
wordEditHunks,
|
||||
type AuthorSpan,
|
||||
type ProposalView,
|
||||
type TaggedSpan,
|
||||
} from "./trackChangesModel";
|
||||
|
||||
export interface AnnotationInputs {
|
||||
baselineText: string | undefined;
|
||||
baselineReason: "entered" | "pinned" | "head" | undefined;
|
||||
spans: AuthorSpan[];
|
||||
proposals: ProposalView[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the sentinel vocabulary into `text` block-by-block (INV-23: code/
|
||||
* mermaid fences stay atomic — never sentinel-injected, never word-refined) so
|
||||
* the delimiter-run + block-marker safety `injectSentinels` carries applies
|
||||
* PER BLOCK, not just at the very top of the whole document (its clamps are
|
||||
* anchored to a block's own start, matching every other caller — `colorByAuthor`
|
||||
* is always invoked per block too).
|
||||
*/
|
||||
function injectAllSentinels(text: string, marks: TaggedSpan[]): string {
|
||||
if (marks.length === 0) return text;
|
||||
const blocks = splitBlocksWithRanges(text);
|
||||
if (blocks.length === 0) return text;
|
||||
let out = "";
|
||||
let cursor = 0;
|
||||
for (const b of blocks) {
|
||||
out += text.slice(cursor, b.start); // preserve inter-block gaps (blank lines) verbatim
|
||||
if (b.type === "prose") {
|
||||
const blockMarks = marks.filter((m) => m.end > b.start && m.start < b.end);
|
||||
out += blockMarks.length ? injectSentinels(b.raw, b.start, blockMarks) : b.raw;
|
||||
} else {
|
||||
out += b.raw; // INV-23: fences are never annotated
|
||||
}
|
||||
cursor = b.end;
|
||||
}
|
||||
out += text.slice(cursor); // trailing content after the last block
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: markdown source -> sentinel-annotated source (authorship + change +
|
||||
* proposal marks vs the F6/F7 baseline). Skips everything when annotations are
|
||||
* disabled, or (the #48 rule) once the baseline was just PINNED with zero
|
||||
* LANDED diff — pending F4 proposals are unaffected by that gate (matching the
|
||||
* shipped preview's "pin→clean, proposals still show", #48/INV-33).
|
||||
*/
|
||||
export function annotateSource(src: string, inputs: AnnotationInputs): string {
|
||||
if (!inputs.enabled) return src;
|
||||
const proposals = inputs.proposals ?? [];
|
||||
const baselineText = inputs.baselineText ?? src; // no baseline → no change-marks
|
||||
const landedText = landedTextOf(src, proposals);
|
||||
const pinnedZeroDiff = inputs.baselineReason === "pinned" && landedText === baselineText;
|
||||
|
||||
const insSpans: TaggedSpan[] = [];
|
||||
const deletions: { at: number; text: string }[] = [];
|
||||
const appliedRanges: { start: number; end: number }[] = [];
|
||||
|
||||
// (c) pending F4 proposals already sit applied in `src` (F12 optimistic
|
||||
// apply, INV-48) — mark the applied range ins-<author> (`p.author` — a
|
||||
// proposal defaults to "claude" per `ProposalView.author`, but a human can
|
||||
// also be the proposer, e.g. a human-authored suggestion routed through the
|
||||
// same F4 pending-proposal path) and resurface the pre-apply original as a
|
||||
// deletion at the same point.
|
||||
for (const p of proposals) {
|
||||
if (p.anchorStart === null || p.anchorEnd === null) continue;
|
||||
appliedRanges.push({ start: p.anchorStart, end: p.anchorEnd });
|
||||
if (p.anchorEnd > p.anchorStart) insSpans.push({ start: p.anchorStart, end: p.anchorEnd, tag: p.author ?? "claude" });
|
||||
const original = p.original ?? p.replaced;
|
||||
if (original) deletions.push({ at: p.anchorStart, text: original });
|
||||
}
|
||||
|
||||
// (a)/(b) committed changes-since-baseline. Each hunk (wordEditHunks(src,
|
||||
// baseline) — start/end in SRC coords, `replacement` = the baseline-side text
|
||||
// for that span) yields (a) insertion marks from the F3 `spans` CLIPPED to the
|
||||
// hunk's changed range — a hunk may straddle more than one author (e.g. two
|
||||
// adjacent words typed by different authors with nothing committed between
|
||||
// them), so spans are clipped individually, never the whole hunk tagged with
|
||||
// one author — and (b) the baseline's dropped text reinserted at the hunk
|
||||
// start. A hunk owned by an applied pending proposal is already marked above.
|
||||
if (!pinnedZeroDiff && landedText !== baselineText) {
|
||||
for (const h of wordEditHunks(src, baselineText)) {
|
||||
if (appliedRanges.some((r) => h.start < r.end && h.end > r.start)) continue;
|
||||
for (const s of inputs.spans) {
|
||||
const start = Math.max(s.start, h.start);
|
||||
const end = Math.min(s.end, h.end);
|
||||
if (end > start) insSpans.push({ start, end, tag: s.author });
|
||||
}
|
||||
if (h.replacement) deletions.push({ at: h.start, text: h.replacement });
|
||||
}
|
||||
}
|
||||
|
||||
if (insSpans.length === 0 && deletions.length === 0) return src;
|
||||
|
||||
// Splice deletion reinsertions into the source high→low (so earlier offsets
|
||||
// stay valid), shifting every mark recorded so far and appending a "del" mark
|
||||
// over the freshly-inserted text.
|
||||
const marks: TaggedSpan[] = insSpans.map((s) => ({ ...s }));
|
||||
let text = src;
|
||||
for (const d of [...deletions].sort((a, b) => b.at - a.at)) {
|
||||
text = text.slice(0, d.at) + d.text + text.slice(d.at);
|
||||
for (const m of marks) {
|
||||
if (m.start >= d.at) m.start += d.text.length;
|
||||
if (m.end >= d.at) m.end += d.text.length;
|
||||
}
|
||||
marks.push({ start: d.at, end: d.at + d.text.length, tag: "del" });
|
||||
}
|
||||
|
||||
return injectAllSentinels(text, marks);
|
||||
}
|
||||
|
||||
/** One `buildMermaidQueue` entry: a changed mermaid fence's own body (for
|
||||
* matching, `mermaidFenceBody` normalization — no trailing newline) paired
|
||||
* with its `diffMermaid`-augmented replacement source. `consumed` is reset by
|
||||
* the `render` wrapper (see `cowritingMarkdownItPlugin`) so the SAME entries
|
||||
* can back a re-render without a fresh parse. */
|
||||
interface MermaidQueueEntry {
|
||||
readonly curBody: string;
|
||||
readonly source: string;
|
||||
consumed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-render queue of mermaid-fence overrides: one entry per `changed` atomic
|
||||
* mermaid op (Task 7 §2.6 parity: reuses `diffBlocks`' baseline/current block
|
||||
* pairing, the SAME pairing `renderReview`/`renderOp` used in the sunset
|
||||
* webview, so a changed mermaid fence is re-emitted through `diffMermaid`
|
||||
* exactly as it was there — INV-23 fences stay atomic, INV-29..31 styling
|
||||
* directives) that has a diffable before/current pair AND a successful
|
||||
* `diffMermaid` augmentation. `[]` when there is no baseline to diff against,
|
||||
* annotations are disabled, or the landed text equals the baseline (covers
|
||||
* the #48 pinned-zero-diff case too, since `diffBlocks` on identical text
|
||||
* yields no `changed` ops).
|
||||
*
|
||||
* Diffed against `landedText` (proposals reverted), matching the "committed
|
||||
* change since baseline" boundary `annotateSource`'s own `wordEditHunks(src,
|
||||
* baselineText)` gate already draws (a pending, not-yet-accepted proposal
|
||||
* isn't a landed change) — a fence a pending proposal's anchor overlaps is
|
||||
* left unaugmented, same as the rest of that gate.
|
||||
*
|
||||
* NOT positional: `src` (what markdown-it actually parses fences from) and
|
||||
* `landedText` (what this function diffs) can disagree on mermaid-fence COUNT
|
||||
* or ORDER — e.g. a pending proposal optimistically applied in `src` (F12,
|
||||
* INV-48) inserts a whole new mermaid fence that `landedText` (proposals
|
||||
* reverted) doesn't have, or `annotateSource`'s committed-deletion reinsert
|
||||
* splices an extra fence into the rendered source. `options.highlight`
|
||||
* (`cowritingMarkdownItPlugin`) matches each queue entry to a fence by BODY,
|
||||
* not by position — a fence with no matching entry renders verbatim (the
|
||||
* queue is a bag of `changed` fences to find, not a strict per-fence
|
||||
* override list), so misalignment degrades to verbatim exactly as intended,
|
||||
* never substitutes the wrong diagram's augmentation into the wrong fence.
|
||||
*
|
||||
* Pure, vscode-free (INV-6).
|
||||
*/
|
||||
function buildMermaidQueue(src: string, inputs: AnnotationInputs): MermaidQueueEntry[] {
|
||||
if (!inputs.enabled || inputs.baselineText === undefined) return [];
|
||||
const baselineText = inputs.baselineText;
|
||||
const landedText = landedTextOf(src, inputs.proposals ?? []);
|
||||
if (landedText === baselineText) return [];
|
||||
const entries: MermaidQueueEntry[] = [];
|
||||
for (const op of diffBlocks(baselineText, landedText)) {
|
||||
if (op.kind !== "changed" || op.block.type !== "mermaid") continue; // unchanged/added/removed: no override needed
|
||||
const curBody = mermaidFenceBody(op.block.raw);
|
||||
const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null;
|
||||
if (curBody === null || beforeBody === null) continue;
|
||||
const result = diffMermaid(beforeBody, curBody);
|
||||
if (result.kind === "augmented") entries.push({ curBody, source: result.source, consumed: false }); // INV-30 fallback: verbatim
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it plugin factory: the host supplies `AnnotationInputs` per render
|
||||
* (`host.inputsFor(env)`; `undefined` = no coediting context for this render —
|
||||
* source passed through untouched). Installs (a) a core rule BEFORE `normalize`
|
||||
* that swaps `state.src` for `annotateSource(...)`, and (b) a full-render
|
||||
* wrapper (see module docstring) that turns surviving sentinel pairs into
|
||||
* `cw-…` spans. Also teaches ```mermaid fences to render as `<pre class="mermaid">`
|
||||
* (Q4/Task 7 Step 2.6) via `options.highlight` — the same extension point the
|
||||
* built-in preview's own fence rule already calls for syntax highlighting — so
|
||||
* the bundled `media/preview-mermaid.js` previewScript has something to run
|
||||
* mermaid over (the built-in preview does not render mermaid on its own). A
|
||||
* CHANGED mermaid fence's source is re-emitted through `diffMermaid` first
|
||||
* (`buildMermaidQueue`, Task 7 §2.6 parity) so mermaid renders the intra-
|
||||
* diagram diff (INV-29..31), not just the current diagram. `options.highlight`
|
||||
* has no `env`/`state` parameter of its own, so the per-render queue is
|
||||
* threaded through a closure variable set by the core rule (which DOES see
|
||||
* `state.env`) and consumed BY BODY MATCH, not fence position (see
|
||||
* `buildMermaidQueue`), by `highlight`: each fence's `code` is matched against
|
||||
* unconsumed queue entries in order, the first match is consumed (so a later
|
||||
* fence with the same body still finds its own entry), and a fence with no
|
||||
* match renders verbatim WITHOUT consuming anything (so a later fence can
|
||||
* still match). markdown-it's `parse` (core rules) always completes before
|
||||
* `renderer.render` invokes `highlight`, and a single md instance never
|
||||
* renders concurrently, so the closure handoff itself is safe — but VS Code
|
||||
* may call `md.parse()` and `md.renderer.render()` separately and re-render
|
||||
* cached tokens without a fresh parse (the same caller pattern the module
|
||||
* docstring notes for scroll-sync), which would otherwise replay a queue whose
|
||||
* entries were already marked consumed by the prior render; the `render`
|
||||
* wrapper resets every entry's `consumed` flag so each render pass starts with
|
||||
* the full queue available again.
|
||||
*/
|
||||
export function cowritingMarkdownItPlugin(
|
||||
md: MarkdownIt,
|
||||
host: { inputsFor(env: unknown): AnnotationInputs | undefined },
|
||||
): MarkdownIt {
|
||||
let mermaidQueue: MermaidQueueEntry[] = [];
|
||||
|
||||
md.core.ruler.before("normalize", "cowriting-annotate", (state) => {
|
||||
const inputs = host.inputsFor(state.env);
|
||||
mermaidQueue = inputs ? buildMermaidQueue(state.src, inputs) : [];
|
||||
if (!inputs) return;
|
||||
state.src = annotateSource(state.src, inputs);
|
||||
});
|
||||
|
||||
const baseRender = md.renderer.render.bind(md.renderer);
|
||||
md.renderer.render = (tokens, options, env) => {
|
||||
for (const entry of mermaidQueue) entry.consumed = false; // re-render-without-reparse safety net
|
||||
return sentinelsToSpans(baseRender(tokens, options, env), "ins");
|
||||
};
|
||||
|
||||
const baseHighlight = md.options.highlight;
|
||||
md.options.highlight = (code, lang, attrs) => {
|
||||
if (lang?.trim().toLowerCase() === "mermaid") {
|
||||
const normalized = code.replace(/\n+$/, ""); // token.content keeps a trailing LF; mermaidFenceBody doesn't
|
||||
const entry = mermaidQueue.find((e) => !e.consumed && e.curBody === normalized);
|
||||
if (entry) entry.consumed = true;
|
||||
const legend = entry ? MERMAID_LEGEND : "";
|
||||
return `<pre class="mermaid" style="all: unset;">${escapeMermaidSource(entry?.source ?? code)}</pre>${legend}`;
|
||||
}
|
||||
return baseHighlight?.(code, lang, attrs) ?? md.utils.escapeHtml(code);
|
||||
};
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
/** Minimal HTML-escape for a mermaid fence body (no markdown parsing — verbatim diagram source). */
|
||||
function escapeMermaidSource(source: string): string {
|
||||
return source
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\n+$/, "");
|
||||
}
|
||||
+318
-9
@@ -13,12 +13,13 @@
|
||||
import * as vscode from "vscode";
|
||||
import { SidecarRouter, docIdentity } from "./sidecarRouter";
|
||||
import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model";
|
||||
import { resolve, shift, type OffsetRange } from "./anchorer";
|
||||
import { addProposal, removeProposal } from "./proposalModel";
|
||||
import { resolve, shift, shiftTracked, buildFingerprint, type OffsetRange } from "./anchorer";
|
||||
import { addProposal, removeProposal, setProposalApplied } from "./proposalModel";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import type { VersionGuard } from "./versionGuard";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import { wordEditHunks, type ProposalView } from "./trackChangesModel";
|
||||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
||||
|
||||
/** Test-facing snapshot of what is currently rendered for a document. */
|
||||
export interface RenderedProposal {
|
||||
@@ -39,6 +40,18 @@ interface DocState {
|
||||
live: Map<string, OffsetRange>;
|
||||
/** proposal ids whose anchor did not resolve at last render (stale/orphaned). */
|
||||
unresolved: Set<string>;
|
||||
/** ids already optimistically applied to the buffer (so the trigger doesn't re-apply). */
|
||||
applied: Set<string>;
|
||||
/**
|
||||
* #70 (INV-5): the F12 applied-range bookkeeping — proposal id -> the applied
|
||||
* span's CURRENT buffer range. Written at optimistic-apply, re-synced whenever
|
||||
* the exact anchor resolves (renderAll), shifted through interior-safe edits,
|
||||
* and DELETED (distrusted) when an edit straddles a span boundary — a clamped
|
||||
* range is a guess, and stale text is never applied by guess (INV-11). Lets
|
||||
* Reject restore the original after the human types INSIDE the pending range
|
||||
* (which orphans the exact-substring anchor).
|
||||
*/
|
||||
appliedSpans: Map<string, OffsetRange>;
|
||||
}
|
||||
|
||||
export class ProposalController implements vscode.Disposable {
|
||||
@@ -54,11 +67,43 @@ export class ProposalController implements vscode.Disposable {
|
||||
private readonly attribution: AttributionController,
|
||||
private readonly rootDir: string | undefined,
|
||||
private readonly guard: VersionGuard,
|
||||
private readonly registry: CoeditingRegistry,
|
||||
) {
|
||||
this.disposables.push(this.statusItem);
|
||||
this.disposables.push(this.onDidChangeProposalsEmitter);
|
||||
this.disposables.push(
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||
// #70: a tracked applied span is only meaningful while its buffer is live —
|
||||
// a closed doc can change on disk with no change events, so a span kept
|
||||
// across close/reopen could revert over unrelated text (INV-11: never by
|
||||
// guess). Cleared here; a resolving anchor rebuilds it at the next renderAll.
|
||||
vscode.workspace.onDidCloseTextDocument((doc) => {
|
||||
this.docs.get(this.keyOf(doc))?.appliedSpans.clear();
|
||||
}),
|
||||
// PUC-7 restore-on-enter (mirrors ThreadController/AttributionController):
|
||||
// `renderAll` is the only path that recomputes state.live/unresolved AND
|
||||
// fires onDidChangeProposals — the event EditorProposalController listens
|
||||
// to for optimistic-apply (decorations/CodeLens). Without this, a FIRST
|
||||
// entry into coediting (registry.enter()) never fires it, so pre-existing
|
||||
// pending proposals stay un-applied/undecorated until the next edit.
|
||||
// `renderAll` already gates on isCoediting; exit intentionally does
|
||||
// nothing here (it clears only the live/unresolved recompute, not the
|
||||
// persisted artifact — EditorProposalController's own gate hides the
|
||||
// decorations). Registered after AttributionController's own subscription
|
||||
// (construction order in extension.ts) so decorateCommitted's spansFor
|
||||
// read sees fresh attribution on the same enter transition.
|
||||
this.registry.onDidChange(({ uri, coediting }) => {
|
||||
if (!coediting) {
|
||||
// Finding 5 (final whole-branch review): exit hides the status-bar
|
||||
// item too — otherwise a stale "N stale proposals" warning for a
|
||||
// doc that's no longer even coediting stays pinned in the bar until
|
||||
// some unrelated render happens to fire for it.
|
||||
if (vscode.window.activeTextEditor?.document.uri.toString() === uri) this.statusItem.hide();
|
||||
return;
|
||||
}
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (doc) this.renderAll(doc);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,8 +134,10 @@ export class ProposalController implements vscode.Disposable {
|
||||
id: p.id,
|
||||
anchorStart: resolved === "orphaned" ? null : resolved.start,
|
||||
anchorEnd: resolved === "orphaned" ? null : resolved.end,
|
||||
replaced: fp?.text ?? "",
|
||||
replaced: p.original ?? fp?.text ?? "",
|
||||
replacement: p.replacement,
|
||||
original: p.original,
|
||||
author: p.author?.kind === "agent" ? "claude" : "human",
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -104,6 +151,8 @@ export class ProposalController implements vscode.Disposable {
|
||||
artifact: this.store.load(docPath) ?? emptyArtifact(docPath),
|
||||
live: new Map(),
|
||||
unresolved: new Set(),
|
||||
applied: new Set(),
|
||||
appliedSpans: new Map(),
|
||||
};
|
||||
this.docs.set(docPath, state);
|
||||
}
|
||||
@@ -137,8 +186,33 @@ export class ProposalController implements vscode.Disposable {
|
||||
|
||||
// ---- 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> {
|
||||
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);
|
||||
return hit ? this.accept(hit.state, hit.proposal, opts) : false;
|
||||
}
|
||||
@@ -181,6 +255,12 @@ export class ProposalController implements vscode.Disposable {
|
||||
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> {
|
||||
if (this.guard.isReadOnly(state.docPath)) return false;
|
||||
const document = this.openDoc(state);
|
||||
@@ -257,16 +337,227 @@ export class ProposalController implements vscode.Disposable {
|
||||
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;
|
||||
}
|
||||
|
||||
/** #70: the shift-tracked applied span (the revert-fallback bookkeeping) —
|
||||
* exposed so the editor surface can keep the decision gestures reachable
|
||||
* for an applied proposal whose exact anchor an interior tweak orphaned. */
|
||||
trackedSpan(docPath: string, proposalId: string): OffsetRange | undefined {
|
||||
return this.docs.get(docPath)?.appliedSpans.get(proposalId);
|
||||
}
|
||||
|
||||
/** The one proposal-clear sequence (record + in-memory bookkeeping + re-render)
|
||||
* shared by finalize/revert/reject so no path leaves a stale entry behind. */
|
||||
private clearProposal(state: DocState, proposalId: string, document?: vscode.TextDocument): void {
|
||||
this.store.update(state.docPath, (a) => removeProposal(a, proposalId));
|
||||
state.applied.delete(proposalId);
|
||||
state.appliedSpans.delete(proposalId);
|
||||
if (document) this.renderAll(document);
|
||||
}
|
||||
|
||||
/**
|
||||
* F12/#64 (INV-48): optimistically apply a pending proposal INTO the buffer so
|
||||
* the editor shows the would-be-accepted result (editable). Reuses the F4
|
||||
* 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;
|
||||
state.appliedSpans.set(proposalId, { start: appliedStart, end: appliedEnd });
|
||||
const appliedFp = buildFingerprint(document.getText(), { start: appliedStart, end: appliedEnd });
|
||||
this.store.update(docPath, (a) => setProposalApplied(a, proposalId, appliedFp, original));
|
||||
this.renderAll(document);
|
||||
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 marks the attribution landing (via
|
||||
* `attribution.signalLanded`) and clears the proposal. No re-application.
|
||||
* Native-surfaces migration (INV-7/INV-18 retirement, spec §6.4): this no
|
||||
* longer advances the baseline — a landed proposal stays a visible change-
|
||||
* since-baseline until commit (head mode) or "Mark Changes as Reviewed"
|
||||
* (snapshot mode); nothing here subscribes to `signalLanded` for baseline
|
||||
* purposes anymore (the `onDidApplyAgentEdit → advance` wire was removed).
|
||||
*/
|
||||
async finalizeInPlace(docPath: string, proposalId: string): Promise<boolean> {
|
||||
if (this.guard.isReadOnly(docPath)) return false;
|
||||
const hit = this.byId(docPath, proposalId);
|
||||
if (!hit) return false;
|
||||
const document = this.openDoc(hit.state);
|
||||
if (!document) return false;
|
||||
this.attribution.signalLanded(document);
|
||||
this.clearProposal(hit.state, proposalId, document);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* F12/#64 (INV-51): REJECT an optimistically-applied proposal — replace its live
|
||||
* applied span with the stored `original`, then clear it. Reverts the whole block
|
||||
* regardless of any in-place edits the human made to the inserted text: when an
|
||||
* interior tweak has orphaned the exact anchor (INV-11), the revert falls back to
|
||||
* the shift-tracked applied span (#70, INV-5). When neither locates the span
|
||||
* (e.g. a boundary-straddling external rewrite distrusted it), the reject FAILS
|
||||
* loudly — warning shown, proposal kept, `false` returned — instead of silently
|
||||
* leaving the proposed text in the buffer. Undo (or ✓ Keep) remains the way out.
|
||||
*/
|
||||
async revertInPlace(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
|
||||
if (this.guard.isReadOnly(docPath)) return false;
|
||||
const hit = this.byId(docPath, proposalId);
|
||||
if (!hit) return false;
|
||||
const document = this.openDoc(hit.state);
|
||||
if (!document) return false;
|
||||
// Never optimistically applied → nothing of ours is in the buffer; clearing
|
||||
// the record IS the reject (the legacy pending-only path).
|
||||
if (hit.proposal.original === undefined) {
|
||||
this.clearProposal(hit.state, proposalId, document);
|
||||
return true;
|
||||
}
|
||||
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
|
||||
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
|
||||
const span = resolved !== "orphaned" ? resolved : hit.state.appliedSpans.get(proposalId);
|
||||
if (!span) {
|
||||
if (!opts?.silent) {
|
||||
// The revert is refused, but the record must stay dismissable (a window
|
||||
// reload empties both the tracked spans and the undo stack): offer a
|
||||
// record-only discard that leaves the buffer exactly as it stands.
|
||||
const DISCARD = "Discard proposal (leave text)";
|
||||
void vscode.window
|
||||
.showWarningMessage(
|
||||
"Cowriting: this proposal's applied text can't be located (it changed or moved) — it is never reverted by guess. Undo your edits, Keep it, or discard the record as-is.",
|
||||
DISCARD,
|
||||
)
|
||||
.then((choice) => {
|
||||
if (choice === DISCARD) this.clearProposal(hit.state, proposalId, document);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
we.replace(
|
||||
document.uri,
|
||||
new vscode.Range(document.positionAt(span.start), document.positionAt(span.end)),
|
||||
hit.proposal.original,
|
||||
);
|
||||
if (!(await vscode.workspace.applyEdit(we))) return false;
|
||||
this.clearProposal(hit.state, proposalId, document);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* F12/#64 (INV-53): reject EVERY pending proposal on a document — revert each in
|
||||
* DESCENDING span order (so an earlier revert never shifts a later one's
|
||||
* offsets), symmetric with #46's accept-all. #70: a tweaked proposal whose exact
|
||||
* anchor is orphaned orders (and reverts) by its shift-tracked applied span;
|
||||
* one with no locatable span is SKIPPED — counted, never guessed (INV-11) —
|
||||
* and the per-proposal warning is suppressed in favor of the batch report.
|
||||
*/
|
||||
async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number; skipped: number }> {
|
||||
if (!this.isTracked(document)) return { reverted: 0, skipped: 0 };
|
||||
const docPath = this.keyOf(document);
|
||||
const state = this.ensureState(document);
|
||||
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
|
||||
const text = document.getText();
|
||||
const ordered = state.artifact.proposals
|
||||
.map((p) => {
|
||||
const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
|
||||
const r = fp ? resolve(text, fp) : "orphaned";
|
||||
const start = r !== "orphaned" ? r.start : state.appliedSpans.get(p.id)?.start ?? -1;
|
||||
return { id: p.id, start };
|
||||
})
|
||||
.sort((a, b) => b.start - a.start);
|
||||
let reverted = 0;
|
||||
let skipped = 0;
|
||||
for (const it of ordered) {
|
||||
if (await this.revertInPlace(docPath, it.id, { silent: true })) reverted++;
|
||||
else skipped++;
|
||||
}
|
||||
return { reverted, skipped };
|
||||
}
|
||||
|
||||
private reject(state: DocState, proposal: Proposal): void {
|
||||
if (this.guard.isReadOnly(state.docPath)) return;
|
||||
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
|
||||
const document = this.openDoc(state);
|
||||
if (document) this.renderAll(document);
|
||||
this.clearProposal(state, proposal.id, this.openDoc(state));
|
||||
}
|
||||
|
||||
// ---- PUC-4: load / external change / resolve-or-flag --------------------------------
|
||||
|
||||
/** Load + (re)render every pending proposal at its resolved anchor (or flagged). */
|
||||
/** Load + (re)render every pending proposal at its resolved anchor (or flagged).
|
||||
* INV-10: the RENDER portion (live/unresolved recompute, the status bar, and
|
||||
* the onDidChangeProposals fire that drives F12's optimistic-apply/CodeLens/
|
||||
* decorations) is gated on coediting — `propose()` itself stays ungated (it
|
||||
* always persists), and so does the state LOAD below: `acceptById`/`rejectById`
|
||||
* key off `this.docs` (populated here) regardless of coediting, since deciding
|
||||
* an already-persisted proposal is not a "rendering" surface. */
|
||||
renderAll(document: vscode.TextDocument): void {
|
||||
if (!this.isTracked(document)) return;
|
||||
const docPath = this.keyOf(document);
|
||||
@@ -274,6 +565,7 @@ export class ProposalController implements vscode.Disposable {
|
||||
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
|
||||
state.live.clear();
|
||||
state.unresolved.clear();
|
||||
if (!this.registry.isCoediting(document.uri)) return;
|
||||
const text = document.getText();
|
||||
for (const proposal of state.artifact.proposals) {
|
||||
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
|
||||
@@ -284,6 +576,14 @@ export class ProposalController implements vscode.Disposable {
|
||||
this.recordProposal(state, proposal, { start: off, end: off }, false);
|
||||
} else {
|
||||
this.recordProposal(state, proposal, resolved, true);
|
||||
// #70: REBUILD the applied-range bookkeeping from an exact resolve when
|
||||
// the map has no entry (a prior-session apply, or after a close/reopen
|
||||
// clear). An entry already present has been shift-maintained continuously
|
||||
// since apply and is ground truth — never clobbered by a resolve, which
|
||||
// can land on an exact duplicate of the applied text elsewhere.
|
||||
if (state.applied.has(proposal.id) && !state.appliedSpans.has(proposal.id)) {
|
||||
state.appliedSpans.set(proposal.id, resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.renderStatus(state);
|
||||
@@ -302,10 +602,19 @@ export class ProposalController implements vscode.Disposable {
|
||||
|
||||
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||
const state = this.docs.get(this.keyOf(e.document));
|
||||
if (!state || state.live.size === 0) return;
|
||||
if (!state || (state.live.size === 0 && state.appliedSpans.size === 0)) return;
|
||||
for (const change of e.contentChanges) {
|
||||
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
|
||||
for (const [id, range] of state.live) state.live.set(id, shift(range, edit));
|
||||
// #70: an edit fully inside a tracked applied span (or fully outside it)
|
||||
// keeps the span meaningful; one straddling a boundary — e.g. a whole-buffer
|
||||
// external replace — makes the shifted range a clamped guess, so the span
|
||||
// is distrusted (deleted) rather than reverted-to by guess (INV-11).
|
||||
for (const [id, range] of state.appliedSpans) {
|
||||
const shifted = shiftTracked(range, edit);
|
||||
if (shifted === "distrusted") state.appliedSpans.delete(id);
|
||||
else state.appliedSpans.set(id, shifted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,25 @@ export function removeProposal(artifact: Artifact, proposalId: string): boolean
|
||||
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. */
|
||||
export function proposalBody(targetText: string, p: Proposal): string {
|
||||
const header = p.instruction ? `**Claude proposes** — _${p.instruction}_` : "**Claude proposes**";
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* ScmSurfaceController — the native diff surface (spec §6.4/§5, INV-13).
|
||||
* Owns: the "Cowriting" SourceControl + QuickDiffProvider (gutter bars), the
|
||||
* cowriting-baseline: TextDocumentContentProvider, the "Review Changes" native
|
||||
* diff command, and the "✦ Coediting · N changes" status-bar item. Gated on
|
||||
* the CoeditingRegistry (INV-10): non-entered docs resolve no original.
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
||||
import type { DiffViewController } from "./diffViewController";
|
||||
import { countLineHunks } from "./trackChangesModel";
|
||||
|
||||
export const BASELINE_SCHEME = "cowriting-baseline";
|
||||
|
||||
export function baselineUriFor(docUri: vscode.Uri): vscode.Uri {
|
||||
const name = path.basename(docUri.path) || "untitled.md";
|
||||
return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: `/${name}`, query: encodeURIComponent(docUri.toString()) });
|
||||
}
|
||||
|
||||
export class ScmSurfaceController implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly statusItem: vscode.StatusBarItem;
|
||||
private readonly counts = new Map<string, number>();
|
||||
private readonly baselineEmitter = new vscode.EventEmitter<vscode.Uri>();
|
||||
private readonly recountTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
constructor(
|
||||
private readonly registry: CoeditingRegistry,
|
||||
private readonly diffView: DiffViewController,
|
||||
) {
|
||||
this.statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
||||
this.statusItem.command = "cowriting.reviewChanges";
|
||||
this.statusItem.tooltip = "Cowriting — click to review changes (baseline ⟷ live)";
|
||||
this.disposables.push(this.statusItem, this.baselineEmitter);
|
||||
|
||||
this.disposables.push(
|
||||
vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, {
|
||||
onDidChange: this.baselineEmitter.event,
|
||||
provideTextDocumentContent: (uri) =>
|
||||
this.diffView.getBaseline(decodeURIComponent(uri.query))?.text ?? "",
|
||||
}),
|
||||
);
|
||||
|
||||
const sc = vscode.scm.createSourceControl("cowriting", "✦ Cowriting");
|
||||
sc.quickDiffProvider = {
|
||||
provideOriginalResource: (uri: vscode.Uri) =>
|
||||
this.registry.isCoediting(uri) && this.diffView.getBaseline(uri.toString())
|
||||
? baselineUriFor(uri)
|
||||
: null,
|
||||
};
|
||||
this.disposables.push(sc);
|
||||
|
||||
this.disposables.push(
|
||||
vscode.commands.registerCommand("cowriting.reviewChanges", () => this.openReview()),
|
||||
this.diffView.onDidChangeBaseline(({ uri }) => {
|
||||
this.baselineEmitter.fire(baselineUriFor(vscode.Uri.parse(uri)));
|
||||
this.scheduleRecount(uri);
|
||||
}),
|
||||
this.registry.onDidChange(({ uri, coediting }) => {
|
||||
if (coediting) this.scheduleRecount(uri);
|
||||
this.refreshStatus();
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument((e) => {
|
||||
if (this.registry.isCoediting(e.document.uri)) this.scheduleRecount(e.document.uri.toString());
|
||||
}),
|
||||
vscode.window.onDidChangeActiveTextEditor(() => this.refreshStatus()),
|
||||
);
|
||||
this.refreshStatus();
|
||||
}
|
||||
|
||||
changeCount(uriString: string): number {
|
||||
return this.counts.get(uriString) ?? 0;
|
||||
}
|
||||
|
||||
private async openReview(): Promise<void> {
|
||||
const ed = vscode.window.activeTextEditor;
|
||||
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
|
||||
void vscode.window.showWarningMessage("Cowriting: not coediting this document.");
|
||||
return;
|
||||
}
|
||||
this.baselineEmitter.fire(baselineUriFor(ed.document.uri));
|
||||
await vscode.commands.executeCommand(
|
||||
"vscode.diff",
|
||||
baselineUriFor(ed.document.uri),
|
||||
ed.document.uri,
|
||||
`${path.basename(ed.document.uri.path)} — Coediting (baseline ⟷ live)`,
|
||||
{ preview: true },
|
||||
);
|
||||
}
|
||||
|
||||
private scheduleRecount(uriString: string): void {
|
||||
const prev = this.recountTimers.get(uriString);
|
||||
if (prev !== undefined) clearTimeout(prev);
|
||||
this.recountTimers.set(uriString, setTimeout(() => {
|
||||
this.recountTimers.delete(uriString);
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
const baseline = this.diffView.getBaseline(uriString);
|
||||
if (!doc || !baseline) return;
|
||||
this.counts.set(uriString, countLineHunks(baseline.text, doc.getText()));
|
||||
this.refreshStatus();
|
||||
}, 150));
|
||||
}
|
||||
|
||||
private refreshStatus(): void {
|
||||
const ed = vscode.window.activeTextEditor;
|
||||
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
|
||||
this.statusItem.hide();
|
||||
return;
|
||||
}
|
||||
const n = this.counts.get(ed.document.uri.toString()) ?? 0;
|
||||
this.statusItem.text = `$(sparkle) Coediting · ${n} change${n === 1 ? "" : "s"}`;
|
||||
this.statusItem.show();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const t of this.recountTimers.values()) clearTimeout(t);
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
+293
-16
@@ -15,6 +15,10 @@ import { gitUserEmail } from "./identity";
|
||||
import { addThread, appendMessage, setStatus } from "./threadModel";
|
||||
import type { VersionGuard } from "./versionGuard";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
||||
import type { EditFlow, EditTarget } from "./editFlow";
|
||||
import type { LiveProgressUi } from "./liveProgressUi";
|
||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||
|
||||
/** Test-facing snapshot of what is currently rendered for a document. */
|
||||
export interface RenderedThread {
|
||||
@@ -22,8 +26,22 @@ export interface RenderedThread {
|
||||
status: "open" | "resolved";
|
||||
orphaned: boolean;
|
||||
range: { startLine: number; endLine: number };
|
||||
/** Finding 2 (final whole-branch review): the raw `contextValue` VS Code's
|
||||
* `comments/commentThread/*` `when`-clauses key on — exposed directly so
|
||||
* E2E can assert the status token survives an offer transition without a
|
||||
* DOM/UI query. */
|
||||
contextValue: string;
|
||||
}
|
||||
|
||||
/** D10/PUC-8: every human comment on a coedited doc runs this reply turn. */
|
||||
const REPLY_PROMPT = (ask: string): string =>
|
||||
"Reply conversationally (2-4 sentences) to this remark about the text, then, if the remark implies " +
|
||||
"an edit, state the edit you would make. Remark: " +
|
||||
ask;
|
||||
|
||||
/** F6.x/Task 6: the injectable low-level turn runner behind respondInThread. */
|
||||
type TurnRunner = (instruction: string, context: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
|
||||
|
||||
interface DocState {
|
||||
docPath: string;
|
||||
uri: vscode.Uri;
|
||||
@@ -34,25 +52,45 @@ interface DocState {
|
||||
live: Map<string, OffsetRange>;
|
||||
/** thread id -> orphaned flag for the current render. */
|
||||
orphaned: Map<string, boolean>;
|
||||
/** Finding 2 (final whole-branch review): thread id -> in-flight offer-flow
|
||||
* token ("offer" once a machine reply lands, "offerdone" once spent),
|
||||
* tracked SEPARATELY from the open/resolved/orphaned status so setting one
|
||||
* never clobbers the other — both are folded into one space-separated
|
||||
* `contextValue` string by `applyContextValue` (package.json when-clauses
|
||||
* match either token with an unanchored regex). Ephemeral: reset on every
|
||||
* render (a fresh vsThread never carries a stale offer). */
|
||||
offerToken: Map<string, "offer" | "offerdone">;
|
||||
}
|
||||
|
||||
export class ThreadController implements vscode.Disposable {
|
||||
private readonly controller: vscode.CommentController;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly docs = new Map<string, DocState>(); // keyed by docPath
|
||||
/** thread -> the pending machine "offer" (D8): the ask it answered, ready to become an edit. */
|
||||
private readonly offers = new WeakMap<vscode.CommentThread, { ask: string; threadId: string }>();
|
||||
/** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */
|
||||
private turnRunner: TurnRunner | undefined;
|
||||
|
||||
/** Kept as ONE object; re-assigned on every registry change so VS Code re-queries
|
||||
* (spec §6.4 v0.2.1 — reassignment is the API's only "ranges changed" signal). */
|
||||
private readonly rangeProvider: vscode.CommentingRangeProvider = {
|
||||
provideCommentingRanges: (document) => {
|
||||
if (!isAuthorable(document.uri.scheme)) return [];
|
||||
if (!this.registry.isCoediting(document.uri)) return [];
|
||||
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
|
||||
},
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly store: SidecarRouter,
|
||||
private readonly rootDir: string | undefined,
|
||||
private readonly guard: VersionGuard,
|
||||
private readonly registry: CoeditingRegistry,
|
||||
private readonly editFlow: EditFlow,
|
||||
private readonly liveProgressUi: LiveProgressUi,
|
||||
) {
|
||||
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
|
||||
this.controller.commentingRangeProvider = {
|
||||
provideCommentingRanges: (document) => {
|
||||
if (!isAuthorable(document.uri.scheme)) return [];
|
||||
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
|
||||
},
|
||||
};
|
||||
this.controller.commentingRangeProvider = this.rangeProvider;
|
||||
this.disposables.push(this.controller);
|
||||
|
||||
this.disposables.push(
|
||||
@@ -64,8 +102,30 @@ export class ThreadController implements vscode.Disposable {
|
||||
vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) =>
|
||||
this.setThreadStatus(t, "open"),
|
||||
),
|
||||
// D19 (spec §6.4 v0.2.1): the comments-first "Ask Claude" gesture — a
|
||||
// focused comment box, not a webview.
|
||||
vscode.commands.registerCommand("cowriting.askClaude", () => this.askClaude()),
|
||||
// D8: turn a machine "offer" thread into pending F4 proposal(s) (INV-5).
|
||||
vscode.commands.registerCommand(
|
||||
"cowriting.makeThreadEdit",
|
||||
(arg: { thread: vscode.CommentThread } | vscode.CommentThread) => this.makeEditFromThreadArg(arg),
|
||||
),
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
|
||||
// INV-10 (PUC-7): force VS Code to re-query commenting ranges on every gate
|
||||
// change, and hide/restore rendered threads — exit hides only (dispose the
|
||||
// rendered vsThreads), re-enter restores (renderAll reloads from the
|
||||
// sidecar, which is the durable source of truth regardless of gate state).
|
||||
this.registry.onDidChange(({ uri, coediting }) => {
|
||||
this.controller.commentingRangeProvider = this.rangeProvider;
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (!doc) return;
|
||||
if (coediting) {
|
||||
this.renderAll(doc);
|
||||
} else {
|
||||
this.disposeRendered(this.keyOf(doc));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,17 +160,44 @@ export class ThreadController implements vscode.Disposable {
|
||||
vsThreads: new Map(),
|
||||
live: new Map(),
|
||||
orphaned: new Map(),
|
||||
offerToken: new Map(),
|
||||
};
|
||||
this.docs.set(docPath, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// ---- D19: comments-first ask -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* D19 (spec §6.4 v0.2.1): "Ask Claude" opens a focused comment box on the
|
||||
* active editor's selection, or, when there is no selection, a top-anchored
|
||||
* whole-document thread. The ask itself IS a comment — respondInThread (D10)
|
||||
* takes it from there once the human submits it via cowriting.reply.
|
||||
*/
|
||||
async askClaude(): Promise<void> {
|
||||
const ed = vscode.window.activeTextEditor;
|
||||
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
|
||||
void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first.");
|
||||
return;
|
||||
}
|
||||
if (ed.selection.isEmpty) {
|
||||
// whole-document ask → top-anchored thread (D19)
|
||||
ed.selection = new vscode.Selection(0, 0, 0, 0);
|
||||
ed.revealRange(new vscode.Range(0, 0, 0, 0));
|
||||
}
|
||||
// Spike finding: workbench.action.addComment is the only path that opens the
|
||||
// comment widget WITH its input focused (no thread.focus() API exists).
|
||||
this.controller.commentingRangeProvider = this.rangeProvider; // defensive refresh
|
||||
await vscode.commands.executeCommand("workbench.action.addComment");
|
||||
}
|
||||
|
||||
// ---- PUC-1: create on selection -------------------------------------------------
|
||||
|
||||
async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.selection.isEmpty || !isAuthorable(editor.document.uri.scheme)) return undefined;
|
||||
if (!this.registry.isCoediting(editor.document.uri)) return undefined;
|
||||
if (this.guard.isReadOnly(this.keyOf(editor.document))) return undefined;
|
||||
const document = editor.document;
|
||||
const state = this.ensureState(document);
|
||||
@@ -119,22 +206,175 @@ export class ThreadController implements vscode.Disposable {
|
||||
end: document.offsetAt(editor.selection.end),
|
||||
};
|
||||
const fp = buildFingerprint(document.getText(), offsets);
|
||||
const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody });
|
||||
const author = this.currentAuthor();
|
||||
const { threadId } = addThread(state.artifact, fp, { author, body: firstBody });
|
||||
this.persist(state);
|
||||
this.renderThread(document, state, threadId, offsets, false);
|
||||
const vsThread = this.renderThread(document, state, threadId, offsets, false);
|
||||
// D10: the first message of a new thread is also "a comment on a coedited
|
||||
// doc" — run the same respond loop as a reply. Finding 2 guard: only for a
|
||||
// human-authored message (never re-trigger the loop off the machine's own
|
||||
// words — see reply() below for the matching guard).
|
||||
if (author.kind !== "agent") {
|
||||
void this.respondInThread(vsThread, state, threadId, firstBody);
|
||||
}
|
||||
return threadId;
|
||||
}
|
||||
|
||||
// ---- PUC-2: reply / resolve -----------------------------------------------------
|
||||
|
||||
reply(r: vscode.CommentReply): void {
|
||||
const threadId = this.threadIdOf(r.thread);
|
||||
const state = this.stateOfThread(r.thread);
|
||||
if (!threadId || !state) return;
|
||||
const vsThread = r.thread;
|
||||
if (!isAuthorable(vsThread.uri.scheme) || !this.registry.isCoediting(vsThread.uri)) return;
|
||||
const document = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString());
|
||||
if (!document) return;
|
||||
const state = this.ensureState(document);
|
||||
if (this.guard.isReadOnly(state.docPath)) return;
|
||||
appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text });
|
||||
const author = this.currentAuthor();
|
||||
let threadId = this.threadIdOf(vsThread);
|
||||
if (threadId) {
|
||||
appendMessage(state.artifact, threadId, { author, body: r.text });
|
||||
} else {
|
||||
// D10/D19: a brand-new thread created via the native "+"/comment-widget
|
||||
// gesture (not via createThreadOnSelection) — VS Code hands us a real,
|
||||
// still-unregistered CommentThread; register it now.
|
||||
const range = vsThread.range ?? new vscode.Range(0, 0, 0, 0);
|
||||
const offsets: OffsetRange = {
|
||||
start: document.offsetAt(range.start),
|
||||
end: document.offsetAt(range.end),
|
||||
};
|
||||
const fp = buildFingerprint(document.getText(), offsets);
|
||||
const created = addThread(state.artifact, fp, { author, body: r.text });
|
||||
threadId = created.threadId;
|
||||
state.vsThreads.set(threadId, vsThread);
|
||||
state.live.set(threadId, offsets);
|
||||
state.orphaned.set(threadId, false);
|
||||
// Finding 2 (final whole-branch review): a thread born via the native
|
||||
// "+" gesture skipped renderThread entirely, so it never got a status
|
||||
// contextValue at all — Resolve/Reopen never appeared for it. Stamp one
|
||||
// now, same as every other thread.
|
||||
this.applyContextValue(state, threadId, vsThread);
|
||||
}
|
||||
this.persist(state);
|
||||
this.refreshComments(r.thread, state, threadId);
|
||||
this.refreshComments(vsThread, state, threadId);
|
||||
// D10: every HUMAN comment on a coedited doc runs a turn. Finding 2 guard:
|
||||
// `cowriting.reply` is the ingress for both a genuine human reply and (in
|
||||
// principle) a re-dispatched machine message — never loop the machine's own
|
||||
// reply back through respondInThread (it would double-turn on Claude's words
|
||||
// and re-flip an already-"offer" thread).
|
||||
if (author.kind !== "agent") {
|
||||
void this.respondInThread(vsThread, state, threadId, r.text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* D10/PUC-8: run one reply turn for `ask`, then append the machine's reply
|
||||
* (INV-8 onBehalfOf provenance) and flip the thread into an "offer" — ready
|
||||
* for makeThreadEdit to cut it into pending proposal(s) (D8, INV-5).
|
||||
*/
|
||||
private async respondInThread(
|
||||
vsThread: vscode.CommentThread,
|
||||
state: DocState,
|
||||
threadId: string,
|
||||
ask: string,
|
||||
): Promise<void> {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString());
|
||||
if (!doc) return;
|
||||
const contextText = this.threadContextText(doc, vsThread);
|
||||
try {
|
||||
await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…", cancellable: true },
|
||||
async (progress, token) => {
|
||||
const run = this.turnRunner ?? (await import("./liveTurn")).runEditTurn;
|
||||
const ui = this.liveProgressUi.begin(ask, progress, token);
|
||||
let turn: EditTurnResult;
|
||||
try {
|
||||
turn = await run(REPLY_PROMPT(ask), contextText, { onProgress: ui.onProgress, signal: ui.signal });
|
||||
} catch (err) {
|
||||
if (token.isCancellationRequested) return;
|
||||
throw err;
|
||||
}
|
||||
// machine reply, onBehalfOf provenance (INV-8)
|
||||
appendMessage(state.artifact, threadId, {
|
||||
author: {
|
||||
kind: "agent",
|
||||
id: "claude",
|
||||
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId, onBehalfOf: this.currentAuthor() },
|
||||
},
|
||||
body: turn.replacement,
|
||||
});
|
||||
this.persist(state);
|
||||
this.refreshComments(vsThread, state, threadId);
|
||||
// Finding 2: fold "offer" in alongside the status token — see
|
||||
// `applyContextValue` — instead of clobbering it (when-key:
|
||||
// `commentThread =~ /\boffer\b/`).
|
||||
state.offerToken.set(threadId, "offer");
|
||||
this.applyContextValue(state, threadId, vsThread);
|
||||
this.offers.set(vsThread, { ask, threadId });
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
void vscode.window.showErrorMessage(`Cowriting: Claude reply failed — ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The passage a ranged thread discusses, or the whole document for a top-anchored (D19) thread. */
|
||||
private threadContextText(doc: vscode.TextDocument, vsThread: vscode.CommentThread): string {
|
||||
const range = vsThread.range;
|
||||
if (range && !range.isEmpty) return doc.getText(range);
|
||||
return doc.getText();
|
||||
}
|
||||
|
||||
/** D19: a ranged thread targets its range; a top-anchored (whole-doc-ask) thread targets the document. */
|
||||
private targetOf(doc: vscode.TextDocument, vsThread: vscode.CommentThread): EditTarget {
|
||||
const range = vsThread.range;
|
||||
if (!range || range.isEmpty) return { kind: "document" };
|
||||
return { kind: "range", start: doc.offsetAt(range.start), end: doc.offsetAt(range.end) };
|
||||
}
|
||||
|
||||
// ---- D8: offer -> pending proposal(s) (INV-5) -------------------------------------
|
||||
|
||||
private async makeEditFromThreadArg(arg: { thread: vscode.CommentThread } | vscode.CommentThread): Promise<void> {
|
||||
const vsThread = "thread" in arg ? arg.thread : arg;
|
||||
await this.runMakeEdit(vsThread);
|
||||
}
|
||||
|
||||
private async runMakeEdit(vsThread: vscode.CommentThread): Promise<string[]> {
|
||||
const offer = this.offers.get(vsThread);
|
||||
if (!offer || !this.registry.isCoediting(vsThread.uri)) return [];
|
||||
const doc = await vscode.workspace.openTextDocument(vsThread.uri);
|
||||
const target = this.targetOf(doc, vsThread);
|
||||
const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask);
|
||||
// Finding 2 (final whole-branch review): fold "offerdone" in alongside the
|
||||
// status token rather than clobbering it — see `applyContextValue`.
|
||||
const state = this.stateOfThread(vsThread);
|
||||
if (state) {
|
||||
state.offerToken.set(offer.threadId, "offerdone");
|
||||
this.applyContextValue(state, offer.threadId, vsThread);
|
||||
}
|
||||
// Finding 3 (seam hygiene): drop the offer once it's spent — otherwise the
|
||||
// `makeThreadEdit(threadId, docPath)` test seam (or a stray second UI click,
|
||||
// now dead per contextValue but still a valid direct call) could re-run the
|
||||
// same edit a second time. A future offer on this thread is a fresh
|
||||
// respondInThread() call, which re-populates the WeakMap entry.
|
||||
this.offers.delete(vsThread);
|
||||
if (ids.length) {
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: ${ids.length} pending change${ids.length === 1 ? "" : "s"} landed in the buffer — ✓ Keep / ✗ Reject there.`,
|
||||
);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Test seam (Task 6): run the offer for a thread without the UI button. */
|
||||
async makeThreadEdit(threadId: string, docPath: string): Promise<string[]> {
|
||||
const vsThread = this.docs.get(docPath)?.vsThreads.get(threadId);
|
||||
if (!vsThread) return [];
|
||||
return this.runMakeEdit(vsThread);
|
||||
}
|
||||
|
||||
/** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */
|
||||
setTurnRunnerForTest(fn: TurnRunner): void {
|
||||
this.turnRunner = fn;
|
||||
}
|
||||
|
||||
private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void {
|
||||
@@ -145,13 +385,14 @@ export class ThreadController implements vscode.Disposable {
|
||||
setStatus(state.artifact, threadId, status);
|
||||
this.persist(state);
|
||||
vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
|
||||
vsThread.contextValue = status;
|
||||
this.applyContextValue(state, threadId, vsThread); // preserves any in-flight offer token
|
||||
}
|
||||
|
||||
// ---- PUC-3/4: render, reload, re-anchor -----------------------------------------
|
||||
|
||||
/** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */
|
||||
renderAll(document: vscode.TextDocument): void {
|
||||
if (!this.registry.isCoediting(document.uri)) return;
|
||||
const docPath = this.keyOf(document);
|
||||
const state = this.ensureState(document);
|
||||
// fresh artifact from disk (reload / external change)
|
||||
@@ -160,6 +401,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
state.vsThreads.clear();
|
||||
state.live.clear();
|
||||
state.orphaned.clear();
|
||||
state.offerToken.clear();
|
||||
const text = document.getText();
|
||||
for (const thread of state.artifact.threads) {
|
||||
const fp = state.artifact.anchors[thread.anchorId]?.fingerprint;
|
||||
@@ -225,7 +467,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
threadId: string,
|
||||
offsets: OffsetRange,
|
||||
orphaned: boolean,
|
||||
): void {
|
||||
): vscode.CommentThread {
|
||||
const thread = state.artifact.threads.find((t) => t.id === threadId)!;
|
||||
const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end));
|
||||
const vsThread = this.controller.createCommentThread(
|
||||
@@ -234,12 +476,34 @@ export class ThreadController implements vscode.Disposable {
|
||||
thread.messages.map((m) => this.toComment(m.body, m.author.id)),
|
||||
);
|
||||
vsThread.label = orphaned ? "⚠ Orphaned thread (anchor not found)" : undefined;
|
||||
vsThread.contextValue = orphaned ? "orphaned" : thread.status;
|
||||
vsThread.state = thread.status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
|
||||
vsThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed;
|
||||
state.vsThreads.set(threadId, vsThread);
|
||||
state.live.set(threadId, offsets);
|
||||
state.orphaned.set(threadId, orphaned);
|
||||
state.offerToken.delete(threadId); // a freshly-created vsThread never carries a stale offer
|
||||
this.applyContextValue(state, threadId, vsThread);
|
||||
return vsThread;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finding 2 (final whole-branch review): `contextValue` is the ONE string
|
||||
* VS Code's `comments/commentThread/*` `when`-clauses can key on, but two
|
||||
* INDEPENDENT things need to show through it — the open/resolved/orphaned
|
||||
* status (Resolve/Reopen) and the offer-flow state (makeThreadEdit). Folding
|
||||
* both into one space-separated string (`"open offer"`, `"resolved"`, …)
|
||||
* with package.json `when`s matched by an unanchored `=~ /\btoken\b/` regex
|
||||
* lets either flip without erasing the other — setting the offer flag alone
|
||||
* used to stomp the status token entirely (every human comment now triggers
|
||||
* a reply, so every thread on a coedited doc lost its Resolve/Reopen menu
|
||||
* the moment the machine answered).
|
||||
*/
|
||||
private applyContextValue(state: DocState, threadId: string, vsThread: vscode.CommentThread): void {
|
||||
const orphaned = !!state.orphaned.get(threadId);
|
||||
const thread = state.artifact.threads.find((t) => t.id === threadId);
|
||||
const statusToken = orphaned ? "orphaned" : (thread?.status ?? "open");
|
||||
const offerToken = state.offerToken.get(threadId);
|
||||
vsThread.contextValue = offerToken ? `${statusToken} ${offerToken}` : statusToken;
|
||||
}
|
||||
|
||||
private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void {
|
||||
@@ -269,6 +533,18 @@ export class ThreadController implements vscode.Disposable {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** PUC-7 (INV-10): exit hides only — dispose the rendered vsThreads for a doc
|
||||
* without touching its sidecar-backed artifact (re-enter restores via renderAll). */
|
||||
private disposeRendered(docPath: string): void {
|
||||
const state = this.docs.get(docPath);
|
||||
if (!state) return;
|
||||
for (const vsThread of state.vsThreads.values()) vsThread.dispose();
|
||||
state.vsThreads.clear();
|
||||
state.live.clear();
|
||||
state.orphaned.clear();
|
||||
state.offerToken.clear();
|
||||
}
|
||||
|
||||
// ---- test-facing surface --------------------------------------------------------
|
||||
|
||||
getRendered(docPath: string): RenderedThread[] {
|
||||
@@ -283,6 +559,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
status: t.status,
|
||||
orphaned: !!state.orphaned.get(id),
|
||||
range: { startLine: r ? r.start.line : 0, endLine: r ? r.end.line : 0 },
|
||||
contextValue: typeof vsThread.contextValue === "string" ? vsThread.contextValue : "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
+337
-74
@@ -193,6 +193,30 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[]
|
||||
return ops;
|
||||
}
|
||||
|
||||
/**
|
||||
* #64/Task 3 (INV-6): the number of line-grain hunks between `oldText` and
|
||||
* `newText` — a run of one or more contiguous non-equal lines counts as ONE
|
||||
* hunk (the status-bar "N changes" count). Same LCS walk `diffBlocks` uses
|
||||
* (jsdiff `diffArrays`), at line grain instead of block grain. Pure,
|
||||
* vscode-free, deterministic.
|
||||
*/
|
||||
export function countLineHunks(oldText: string, newText: string): number {
|
||||
const before = oldText.split(/\r?\n/);
|
||||
const after = newText.split(/\r?\n/);
|
||||
const changes = diffArrays(before, after);
|
||||
let hunks = 0;
|
||||
let inHunk = false;
|
||||
for (const ch of changes) {
|
||||
if (ch.added || ch.removed) {
|
||||
if (!inHunk) hunks++;
|
||||
inHunk = true;
|
||||
} else {
|
||||
inHunk = false;
|
||||
}
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/** A contiguous changed region of `currentText` and its replacement (F11). */
|
||||
export interface EditHunk {
|
||||
/** char offset of the hunk's first changed char in currentText. */
|
||||
@@ -263,6 +287,42 @@ export function wordEditHunks(currentText: string, rewrittenText: string): EditH
|
||||
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);
|
||||
|
||||
/**
|
||||
@@ -409,8 +469,13 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
||||
return defaultFence(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
/** Inner source of a ```mermaid fence (drops the fence lines), or null. */
|
||||
function mermaidFenceBody(raw: string): string | null {
|
||||
/**
|
||||
* Inner source of a ```mermaid fence (drops the fence lines), or null. Exported
|
||||
* so `previewAnnotations.ts` can pair a `diffBlocks` mermaid `changed` op's
|
||||
* before/current fence bodies for `diffMermaid` without reinventing this
|
||||
* extraction (Task 7 §2.6 parity).
|
||||
*/
|
||||
export function mermaidFenceBody(raw: string): string | null {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/);
|
||||
if (!open || open[3]?.toLowerCase() !== "mermaid") return null;
|
||||
@@ -419,8 +484,12 @@ function mermaidFenceBody(raw: string): string | null {
|
||||
return body.join("\n");
|
||||
}
|
||||
|
||||
/** F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block. */
|
||||
const MERMAID_LEGEND =
|
||||
/**
|
||||
* F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block.
|
||||
* Exported for reuse by `previewAnnotations.ts`'s built-in-preview mermaid
|
||||
* diff wiring (Task 7 §2.6 parity — same legend markup, both surfaces).
|
||||
*/
|
||||
export const MERMAID_LEGEND =
|
||||
'<div class="cw-mermaid-legend">' +
|
||||
'<span class="cw-leg cw-leg-add">added</span>' +
|
||||
'<span class="cw-leg cw-leg-chg">changed</span>' +
|
||||
@@ -438,15 +507,88 @@ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** The author whose span covers `offset` (half-open [start,end)), or null. */
|
||||
export function authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null {
|
||||
for (const s of spans) if (offset >= s.start && offset < s.end) return s.author;
|
||||
return null;
|
||||
}
|
||||
|
||||
const insClass = (a: AuthorKind | null): string => `cw-ins-${a ?? "none"}`;
|
||||
const delClass = (a: AuthorKind | null): string => `cw-del-${a ?? "none"}`;
|
||||
|
||||
/**
|
||||
* Author-colored word diff for a changed PROSE block in the review.
|
||||
* `afterStart` is the landed-text offset of `afterRaw[0]` so an inserted run's
|
||||
* offset maps into `spans`. Insertions are colored by the author covering their
|
||||
* offset; deletions inherit the author of the insertion they are paired with in
|
||||
* the same contiguous change cluster (adjacency heuristic) — `cw-del-none` when a
|
||||
* cluster has no insertion. Pure, deterministic.
|
||||
*/
|
||||
export function wordDiffByAuthor(
|
||||
beforeRaw: string,
|
||||
afterRaw: string,
|
||||
afterStart: number,
|
||||
spans: AuthorSpan[],
|
||||
): string {
|
||||
const parts = diffWordsWithSpace(beforeRaw, afterRaw);
|
||||
// First pass: find each change cluster's insertion author (first added run).
|
||||
// A cluster is a maximal run of added/removed parts bounded by unchanged parts.
|
||||
let afterOff = afterStart;
|
||||
const clusterAuthor: (AuthorKind | null)[] = []; // per part index, the cluster's del author
|
||||
{
|
||||
let off = afterStart;
|
||||
let i = 0;
|
||||
while (i < parts.length) {
|
||||
const p = parts[i];
|
||||
if (!p.added && !p.removed) {
|
||||
clusterAuthor[i] = null;
|
||||
off += p.value.length;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// a cluster: scan to its end, capturing the first added run's author
|
||||
let j = i;
|
||||
let author: AuthorKind | null = null;
|
||||
let scanOff = off;
|
||||
while (j < parts.length && (parts[j].added || parts[j].removed)) {
|
||||
if (parts[j].added && author === null) author = authorAt(scanOff, spans);
|
||||
if (parts[j].added) scanOff += parts[j].value.length;
|
||||
j++;
|
||||
}
|
||||
for (let k = i; k < j; k++) clusterAuthor[k] = author;
|
||||
off = scanOff;
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
// Second pass: emit.
|
||||
const out: string[] = [];
|
||||
parts.forEach((p, i) => {
|
||||
if (p.added) {
|
||||
const a = authorAt(afterOff, spans);
|
||||
out.push(`<ins class="${insClass(a)}">${p.value}</ins>`);
|
||||
afterOff += p.value.length;
|
||||
} else if (p.removed) {
|
||||
out.push(`<del class="${delClass(clusterAuthor[i] ?? null)}">${p.value}</del>`);
|
||||
} else {
|
||||
out.push(p.value);
|
||||
afterOff += p.value.length;
|
||||
}
|
||||
});
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
|
||||
render?: (src: string) => string;
|
||||
/**
|
||||
* #48: the baseline was just PINNED (`reason === "pinned"`). With zero changes
|
||||
* since that pin, the on-render is fully clean — no authorship coloring — so a
|
||||
* pin reads as "this is my clean starting point". Distinct from a baseline
|
||||
* advanced by a machine-landing (accept), which keeps its authorship coloring
|
||||
* (F10 INV-33). Only consulted by `renderReview`.
|
||||
* #48: the baseline was just PINNED (`reason === "pinned"` — "Mark Changes as
|
||||
* Reviewed" in snapshot mode). With zero changes since that pin, the
|
||||
* on-render is fully clean — no authorship coloring — so a pin reads as
|
||||
* "this is my clean starting point". Distinct from an ordinary accepted
|
||||
* proposal, which — since the native-surfaces migration retired the
|
||||
* machine-landing baseline advance (INV-18/INV-7) — stays a visible change-
|
||||
* since-baseline and keeps its authorship coloring (F10 INV-33). Only
|
||||
* consulted by `renderReview`.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
}
|
||||
@@ -525,24 +667,33 @@ export interface AuthorSpan {
|
||||
author: AuthorKind;
|
||||
}
|
||||
|
||||
// Private-Use-Area sentinels (never appear in real content; markdown-it passes
|
||||
// them through as plain text). Paired open/close per author.
|
||||
const SENT = {
|
||||
claude: { open: "", close: "" },
|
||||
human: { open: "", close: "" },
|
||||
} as const;
|
||||
|
||||
function isCloseSentinel(m: string): boolean {
|
||||
return m === SENT.claude.close || m === SENT.human.close;
|
||||
/**
|
||||
* Task 7 (preview annotations, native-surfaces migration): the sentinel
|
||||
* vocabulary generalizes beyond per-author spans to a plain "del" tag — a
|
||||
* re-surfaced baseline deletion has no author variant (CSS carries one fixed
|
||||
* `.cw-del`, the F10 vocabulary). `injectSentinels`/`sentinelsToSpans` are
|
||||
* exported so `previewAnnotations.ts` can drive the SAME token-aware
|
||||
* discipline (#33/#47) for its 3-way ins-claude/ins-human/del marks;
|
||||
* `colorByAuthor` (F9/F10) keeps its narrower per-author call, unaffected.
|
||||
*/
|
||||
export type SentinelTag = AuthorKind | "del";
|
||||
export interface TaggedSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
tag: SentinelTag;
|
||||
}
|
||||
|
||||
function authorBadge(authors: Set<AuthorKind>): { cls: string; label: string } | null {
|
||||
if (authors.size === 0) return null;
|
||||
if (authors.size > 1) return { cls: "cw-mixed", label: "mixed" };
|
||||
const only = [...authors][0];
|
||||
return only === "claude"
|
||||
? { cls: "cw-by-claude", label: "Claude" }
|
||||
: { cls: "cw-by-human", label: "You" };
|
||||
// Private-Use-Area sentinels (never appear in real content; markdown-it passes
|
||||
// them through as plain text). Paired open/close per tag.
|
||||
const SENT: Record<SentinelTag, { open: string; close: string }> = {
|
||||
claude: { open: "", close: "" },
|
||||
human: { open: "", close: "" },
|
||||
del: { open: "", close: "" },
|
||||
};
|
||||
const SENT_TAGS = Object.keys(SENT) as SentinelTag[];
|
||||
|
||||
function isCloseSentinel(m: string): boolean {
|
||||
return SENT_TAGS.some((t) => SENT[t].close === m);
|
||||
}
|
||||
|
||||
// Markdown emphasis / code delimiters whose RUNS must never be split by an
|
||||
@@ -565,15 +716,42 @@ function clampOffDelimiterRun(raw: string, at: number): number {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
|
||||
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
|
||||
/**
|
||||
* Returns the offset within `raw` after block-level markdown markers (ATX
|
||||
* headings, list markers, blockquote markers). Open sentinels must not be
|
||||
* injected before these markers — a PUA char at position 0 would prevent
|
||||
* markdown-it from recognising the block construct (e.g. `##` is only a
|
||||
* heading marker when it is the very first char on the line).
|
||||
*/
|
||||
function blockContentStart(raw: string): number {
|
||||
// ATX headings: "#{1,6} " at the start of the line.
|
||||
const heading = raw.match(/^#{1,6} /);
|
||||
if (heading) return heading[0].length;
|
||||
// Unordered list markers: "- ", "* ", "+ ".
|
||||
const ul = raw.match(/^[-*+] /);
|
||||
if (ul) return ul[0].length;
|
||||
// Ordered list markers: "N. " or "N) ".
|
||||
const ol = raw.match(/^\d+[.)]\s+/);
|
||||
if (ol) return ol[0].length;
|
||||
// Blockquotes: "> " or ">".
|
||||
const bq = raw.match(/^> ?/);
|
||||
if (bq) return bq[0].length;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Inject paired sentinels into a block's raw text for the (tagged) spans clipped to it. */
|
||||
export function injectSentinels(raw: string, blockStart: number, spans: TaggedSpan[]): string {
|
||||
const inserts: { at: number; marker: string }[] = [];
|
||||
// Open sentinels must not precede block-level markdown markers (heading/list/
|
||||
// blockquote syntax at position 0) — a PUA char before "## " prevents markdown-it
|
||||
// from recognising the heading. Clamp to the content-start offset.
|
||||
const minOpen = blockContentStart(raw);
|
||||
for (const s of spans) {
|
||||
const lo = clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart));
|
||||
const lo = Math.max(minOpen, clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart)));
|
||||
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
|
||||
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
|
||||
inserts.push({ at: lo, marker: SENT[s.author].open });
|
||||
inserts.push({ at: hi, marker: SENT[s.author].close });
|
||||
inserts.push({ at: lo, marker: SENT[s.tag].open });
|
||||
inserts.push({ at: hi, marker: SENT[s.tag].close });
|
||||
}
|
||||
// Apply high offset → low so earlier offsets stay valid. At an equal offset
|
||||
// (one span's close == the next's open), apply opens BEFORE closes so the
|
||||
@@ -584,34 +762,34 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
|
||||
return out;
|
||||
}
|
||||
|
||||
const SENTINEL_OF: Record<string, { author: AuthorKind; open: boolean } | undefined> = {
|
||||
[SENT.claude.open]: { author: "claude", open: true },
|
||||
[SENT.claude.close]: { author: "claude", open: false },
|
||||
[SENT.human.open]: { author: "human", open: true },
|
||||
[SENT.human.close]: { author: "human", open: false },
|
||||
};
|
||||
const ALL_SENTINELS = new RegExp(
|
||||
`[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`,
|
||||
"g",
|
||||
);
|
||||
const SENTINEL_OF: Record<string, { tag: SentinelTag; open: boolean } | undefined> = {};
|
||||
for (const tag of SENT_TAGS) {
|
||||
SENTINEL_OF[SENT[tag].open] = { tag, open: true };
|
||||
SENTINEL_OF[SENT[tag].close] = { tag, open: false };
|
||||
}
|
||||
const ALL_SENTINELS = new RegExp(`[${SENT_TAGS.map((t) => SENT[t].open + SENT[t].close).join("")}]`, "g");
|
||||
|
||||
/**
|
||||
* #33: token-aware replacement of the rendered author sentinels with `cw-by-*`
|
||||
* spans. A naive global string-replace (the old approach) could emit a span that
|
||||
* #33: token-aware replacement of the rendered sentinels with `cw-*` spans. A
|
||||
* naive global string-replace (the old approach) could emit a span that
|
||||
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
|
||||
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
|
||||
* emits the author span only around TEXT runs, CLOSING it before any `<tag>` and
|
||||
* emits the span only around TEXT runs, CLOSING it before any `<tag>` and
|
||||
* REOPENING it after, so a span is always well-nested within the inline elements
|
||||
* (one `<span>` segment per text run). Tags are copied verbatim (with any stray
|
||||
* sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic.
|
||||
* sentinel stripped, so no Private-Use-Area char ever leaks). `kind` picks the
|
||||
* author-class prefix (`cw-by-*`/`cw-ins-*`); the tag-less "del" mark (Task 7,
|
||||
* no author variant) always renders the fixed `cw-del` regardless of `kind`.
|
||||
* Pure, deterministic.
|
||||
*/
|
||||
function sentinelsToSpans(html: string): string {
|
||||
export function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
|
||||
const out: string[] = [];
|
||||
let current: AuthorKind | null = null; // which author region we're inside
|
||||
let current: SentinelTag | null = null; // which region we're inside
|
||||
let spanOpen = false; // whether a <span> is currently open in `out`
|
||||
const classFor = (tag: SentinelTag): string => (tag === "del" ? "cw-del" : `cw-${kind}-${tag}`);
|
||||
const openSpan = () => {
|
||||
if (current && !spanOpen) {
|
||||
out.push(`<span class="cw-by-${current}">`);
|
||||
out.push(`<span class="${classFor(current)}">`);
|
||||
spanOpen = true;
|
||||
}
|
||||
};
|
||||
@@ -625,7 +803,7 @@ function sentinelsToSpans(html: string): string {
|
||||
const ch = html[i];
|
||||
const sentinel = SENTINEL_OF[ch];
|
||||
if (sentinel) {
|
||||
if (sentinel.open) current = sentinel.author; // span opens lazily before the next text char
|
||||
if (sentinel.open) current = sentinel.tag; // span opens lazily before the next text char
|
||||
else {
|
||||
closeSpan();
|
||||
current = null;
|
||||
@@ -659,10 +837,15 @@ export function colorByAuthor(
|
||||
blockStart: number,
|
||||
spans: AuthorSpan[],
|
||||
render: (src: string) => string,
|
||||
kind: "by" | "ins" = "by",
|
||||
): string {
|
||||
const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length);
|
||||
const injected = injectSentinels(raw, blockStart, overlapping);
|
||||
return sentinelsToSpans(render(injected));
|
||||
const injected = injectSentinels(
|
||||
raw,
|
||||
blockStart,
|
||||
overlapping.map((s) => ({ start: s.start, end: s.end, tag: s.author })),
|
||||
);
|
||||
return sentinelsToSpans(render(injected), kind);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -701,6 +884,10 @@ export interface ProposalView {
|
||||
replaced: string;
|
||||
/** the proposed replacement text. */
|
||||
replacement: string;
|
||||
/** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */
|
||||
original?: string;
|
||||
/** Who made the proposal — colors the del/ins. Defaults to "claude". */
|
||||
author?: AuthorKind;
|
||||
}
|
||||
|
||||
function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string {
|
||||
@@ -712,12 +899,19 @@ function proposalBlockHtml(p: ProposalView, render: (src: string) => string): st
|
||||
}
|
||||
};
|
||||
const unanchored = p.anchorStart === null ? " cw-proposal-unanchored" : "";
|
||||
const before = p.replaced ? `<del class="cw-del">${safe(p.replaced)}</del>` : "";
|
||||
const after = `<ins class="cw-add">${safe(p.replacement)}</ins>`;
|
||||
const who = p.author ?? "claude";
|
||||
const before = p.replaced ? `<del class="cw-del-${who}">${safe(p.replaced)}</del>` : "";
|
||||
const after = `<ins class="cw-ins-${who}">${safe(p.replacement)}</ins>`;
|
||||
const actions =
|
||||
`<span class="cw-actions">` +
|
||||
`<button class="cw-accept" data-action="accept">✓</button>` +
|
||||
`<button class="cw-reject" data-action="reject">✗</button>` +
|
||||
`<span class="cw-btngroup">` +
|
||||
`<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>`;
|
||||
return `<div class="cw-proposal${unanchored}" data-proposal-id="${md.utils.escapeHtml(p.id)}">${actions}${before}${after}</div>`;
|
||||
}
|
||||
@@ -725,20 +919,27 @@ function proposalBlockHtml(p: ProposalView, render: (src: string) => string): st
|
||||
function renderReviewOp(
|
||||
op: BlockOp,
|
||||
render: (src: string) => string,
|
||||
colored: (raw: string) => string,
|
||||
src: string,
|
||||
changedHtml?: string,
|
||||
): string {
|
||||
// removed blocks and any changed block (atomic fences diffed whole; non-atomic prose
|
||||
// word-merged) render via renderOp — no author sentinels (deletions neutral, spec §6.7).
|
||||
// `src` is "" for a removed block (no live source — INV-36).
|
||||
// A non-atomic changed prose block is pre-rendered with author-colored ins/del
|
||||
// (changedHtml). Removed blocks and atomic changed fences stay neutral via renderOp
|
||||
// (standalone deletion → neutral, per the adjacency heuristic). `src` is "" for a
|
||||
// removed block (no live source — INV-36).
|
||||
if (op.kind === "changed" && !op.atomic && changedHtml !== undefined) {
|
||||
return `<div class="cw-blk cw-changed"${src}>${changedHtml}</div>`;
|
||||
}
|
||||
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
|
||||
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${colored(op.block.raw)}</div>`;
|
||||
// "added": changedHtml is colorByAuthor sentinel output (markdown-safe, cw-ins-*).
|
||||
// "unchanged": render(raw) — plain, no author coloring (color = "changed since baseline").
|
||||
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${changedHtml ?? render(op.block.raw)}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* On-state body (INV-33): the F7 baseline diff — added/changed PROSE author-colored
|
||||
* via colorByAuthor (F9 sentinels), deletions struck — overlaid with F4 pending
|
||||
* proposals as blue cw-proposal blocks (✓/✗). One pass, pure, vscode-free.
|
||||
* On-state body: the F7 baseline diff — added/changed PROSE author-colored via
|
||||
* wordDiffByAuthor (cw-ins-{author}/cw-del-{author} per token), unchanged blocks render plain,
|
||||
* deletions struck neutral — overlaid with F4 pending proposals as blue cw-proposal
|
||||
* blocks (✓/✗). One pass, pure, vscode-free.
|
||||
*
|
||||
* A resolved proposal renders INLINE, right after the current-side block its anchor
|
||||
* falls in (#31) — so "what is Claude proposing, and where?" is answered by
|
||||
@@ -747,6 +948,26 @@ function renderReviewOp(
|
||||
* INV-34). Deterministic: proposals in the same block are ordered by anchorStart
|
||||
* 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(
|
||||
baselineText: string,
|
||||
currentText: string,
|
||||
@@ -755,18 +976,47 @@ export function renderReview(
|
||||
opts: RenderOptions = {},
|
||||
): string {
|
||||
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
|
||||
// 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
|
||||
// colorByAuthor for every block in this case; data-src mapping and any pending
|
||||
// proposals (review actions, not annotations) are kept below. A baseline advanced
|
||||
// by a machine-landing (accept) is ALSO zero-diff but is NOT pinned — it keeps
|
||||
// its authorship coloring (F10 INV-33), so this is gated on the pin specifically.
|
||||
// coloring, so the pin reads as "this is my clean starting point". Suppress
|
||||
// wordDiffByAuthor for every block in this case; data-src mapping and any pending
|
||||
// proposals (review actions, not annotations) are kept below.
|
||||
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
|
||||
// block, or the nearest preceding block when the anchor sits in a gap). A
|
||||
// resolved anchor before all blocks, and every unresolved proposal, trails.
|
||||
@@ -778,7 +1028,7 @@ export function renderReview(
|
||||
const byBlock = new Map<number, ProposalView[]>();
|
||||
const trailing: ProposalView[] = [];
|
||||
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) {
|
||||
trailing.push(p);
|
||||
continue;
|
||||
@@ -794,9 +1044,22 @@ export function renderReview(
|
||||
for (const op of ops) {
|
||||
const blockIndex = op.kind === "removed" ? -1 : ci;
|
||||
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
||||
const colored = (raw: string): string =>
|
||||
blk && !clean ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
|
||||
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
|
||||
// Only ADDED and CHANGED prose blocks are author-colored (they differ since baseline);
|
||||
// UNCHANGED blocks render plain (color means "changed since baseline").
|
||||
// A non-atomic changed prose block: author-colored word diff (ins by author,
|
||||
// del by adjacency). An added block: sentinel-safe colorByAuthor with kind="ins"
|
||||
// (markdown-it parses the block first, so ## → <h2> before sentinels become spans;
|
||||
// the old wordDiffByAuthor path wrapped raw in <ins> BEFORE parsing — structural bug).
|
||||
// `blk.start` is the landed offset of the after-side text.
|
||||
const changedHtml =
|
||||
blk && !clean
|
||||
? op.kind === "changed" && !op.atomic
|
||||
? render(wordDiffByAuthor(op.before.raw, op.block.raw, blk.start, landedSpans))
|
||||
: op.kind === "added"
|
||||
? colorByAuthor(op.block.raw, blk.start, landedSpans, render, "ins")
|
||||
: undefined
|
||||
: undefined;
|
||||
bodyParts.push(renderReviewOp(op, render, srcAttr(blk), changedHtml));
|
||||
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
||||
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
||||
}
|
||||
|
||||
@@ -1,513 +0,0 @@
|
||||
/**
|
||||
* TrackChangesPreviewController — F7 vscode layer (spec §6.2/§6.4). Owns one
|
||||
* sealed webview panel per markdown document, beside the source editor. On open /
|
||||
* debounced edit / F6 baseline-epoch change it reads the baseline (from the
|
||||
* reused DiffViewController) + the live buffer, runs the pure render engine, and
|
||||
* posts the HTML. Pure read-only: never mutates the document, sidecar, or
|
||||
* baseline (INV-20). The webview is sealed: local assets only, strict CSP,
|
||||
* per-load nonce, no network (INV-21).
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import * as vscode from "vscode";
|
||||
import type { DiffViewController } from "./diffViewController";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import type { ProposalController } from "./proposalController";
|
||||
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
|
||||
import { buildFingerprint } from "./anchorer";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||
import type { LiveProgressUi } from "./liveProgressUi";
|
||||
|
||||
/**
|
||||
* F11: a host edit turn (selection/document text + instruction → rewrite).
|
||||
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
|
||||
* the arg is optional so existing test stubs that ignore it stay valid.
|
||||
*/
|
||||
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
|
||||
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
|
||||
type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
|
||||
|
||||
const VIEW_TYPE = "cowriting.trackChangesPreview";
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
/**
|
||||
* Inbound webview→host messages (intent only — the sealed webview never mutates,
|
||||
* INV-21/35). F10 carried the annotations toggle + ✓/✗ proposal decisions; F11
|
||||
* adds the toolbar intents (pin baseline / ask Claude).
|
||||
*/
|
||||
type ToolbarMsg =
|
||||
| { type: "setMode"; mode: "on" | "off" }
|
||||
| { type: "accept"; proposalId: string }
|
||||
| { type: "reject"; proposalId: string }
|
||||
| { type: "pinBaseline" }
|
||||
| { type: "askClaude"; scope: "document" }
|
||||
| { type: "askClaude"; scope: "selection"; start: number; end: number }
|
||||
| { type: "acceptAll" };
|
||||
|
||||
export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly panels = new Map<string, vscode.WebviewPanel>();
|
||||
private readonly lastModel = new Map<string, BlockOp[]>();
|
||||
private readonly debounces = new Map<string, NodeJS.Timeout>();
|
||||
/** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */
|
||||
private readonly mode = new Map<string, "on" | "off">();
|
||||
/** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */
|
||||
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88);
|
||||
/**
|
||||
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
|
||||
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
|
||||
*/
|
||||
private editTurn: EditTurn = async (instruction, text, opts) => {
|
||||
const { runEditTurn } = await import("./liveTurn");
|
||||
return runEditTurn(instruction, text, opts);
|
||||
};
|
||||
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
|
||||
private turnSeq = 0;
|
||||
private nextTurnSeq(): number {
|
||||
return ++this.turnSeq;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly diffView: DiffViewController,
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
private readonly attribution: AttributionController,
|
||||
private readonly proposals: ProposalController,
|
||||
private readonly liveProgressUi: LiveProgressUi,
|
||||
) {
|
||||
this.disposables.push(
|
||||
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
|
||||
// the palette / keybinding pass nothing → fall back to the active editor.
|
||||
// #41: the explorer/tab right-click also passes the clicked Uri, which may
|
||||
// not be an open document yet — open it so we preview the clicked file, not
|
||||
// whatever happens to be the active editor.
|
||||
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", async (uri?: vscode.Uri) => {
|
||||
if (uri) {
|
||||
const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
|
||||
this.show(open ?? (await vscode.workspace.openTextDocument(uri)));
|
||||
return;
|
||||
}
|
||||
this.show(vscode.window.activeTextEditor?.document);
|
||||
}),
|
||||
// F11: document-scoped Ask-Claude (also reused by #42's reach gateways).
|
||||
// Edits a markdown doc; the rewrite is diffed into F4 proposals.
|
||||
// #42 (INV-38): the editor/title/context (tab) entry passes the clicked
|
||||
// tab's resource Uri — target THAT document, opening it if it isn't already
|
||||
// an open buffer (mirrors showTrackChangesPreview's #41 resolution); the
|
||||
// palette / keybinding / editor/context pass nothing → the active editor.
|
||||
vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => {
|
||||
const doc = uri
|
||||
? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ??
|
||||
(await vscode.workspace.openTextDocument(uri))
|
||||
: vscode.window.activeTextEditor?.document;
|
||||
if (!doc || !this.isMarkdown(doc)) {
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
|
||||
return;
|
||||
}
|
||||
void this.askClaude(doc, { kind: "document" });
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
|
||||
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
|
||||
this.proposals.onDidChangeProposals(({ uri }) => {
|
||||
this.refreshByUri(uri);
|
||||
this.updateStatus(uri);
|
||||
}),
|
||||
this.statusItem,
|
||||
);
|
||||
this.statusItem.command = "cowriting.showTrackChangesPreview";
|
||||
}
|
||||
|
||||
private isMarkdown(document: vscode.TextDocument): boolean {
|
||||
return document.languageId === "markdown";
|
||||
}
|
||||
|
||||
/** Open or reveal the preview for a markdown document (PUC-1). */
|
||||
show(document: vscode.TextDocument | undefined): void {
|
||||
if (!document || !this.isMarkdown(document)) {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: open a Markdown document to use the track-changes preview (F6 covers other files).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = document.uri.toString();
|
||||
const existing = this.panels.get(key);
|
||||
if (existing) {
|
||||
existing.reveal(vscode.ViewColumn.Beside);
|
||||
this.refresh(document);
|
||||
return;
|
||||
}
|
||||
const name = path.basename(document.uri.path) || "untitled";
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
VIEW_TYPE,
|
||||
`Review: ${name}`,
|
||||
{ viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: false,
|
||||
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "out", "media")],
|
||||
},
|
||||
);
|
||||
panel.webview.html = this.shellHtml(panel.webview);
|
||||
panel.onDidDispose(
|
||||
() => {
|
||||
this.panels.delete(key);
|
||||
this.lastModel.delete(key);
|
||||
this.mode.delete(key);
|
||||
// A panel is gone: re-show the off-panel indicator if proposals remain.
|
||||
this.updateStatus(key);
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
// F10/F11: the webview posts the annotations toggle, ✓/✗ proposal decisions,
|
||||
// and (F11) the toolbar intents (pin baseline / ask Claude) back to the host.
|
||||
panel.webview.onDidReceiveMessage(
|
||||
(m: ToolbarMsg) => this.handleWebviewMessage(document, m),
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
this.panels.set(key, panel);
|
||||
// A panel is now open for this doc — the off-panel indicator is redundant.
|
||||
this.hideStatus();
|
||||
this.refresh(document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an inbound webview intent through the existing seams (INV-35): the
|
||||
* annotations toggle + ✓/✗ proposal decisions (F10) and the toolbar gestures
|
||||
* (F11). Pin targets the PREVIEWED document (`DiffViewController.pin`), not the
|
||||
* active editor — the preview knows its bound doc (§6.7).
|
||||
*/
|
||||
private handleWebviewMessage(document: vscode.TextDocument, m: ToolbarMsg): void {
|
||||
const key = document.uri.toString();
|
||||
if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) {
|
||||
this.mode.set(key, m.mode);
|
||||
this.refresh(document);
|
||||
} else if (m?.type === "accept" && m.proposalId) {
|
||||
void this.proposals
|
||||
.acceptById(this.proposals.keyFor(document), m.proposalId)
|
||||
.then(() => this.refresh(document));
|
||||
} else if (m?.type === "reject" && m.proposalId) {
|
||||
this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId);
|
||||
this.refresh(document);
|
||||
} else if (m?.type === "pinBaseline") {
|
||||
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
|
||||
this.diffView.pin(document);
|
||||
} else if (m?.type === "askClaude") {
|
||||
const target: EditTarget =
|
||||
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
|
||||
void this.askClaude(document, target);
|
||||
} else if (m?.type === "acceptAll") {
|
||||
// #46 (INV-42): batch-accept every pending proposal on this doc, then report.
|
||||
void this.acceptAll(document);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #46 (INV-42): apply every pending proposal on the document through the F4
|
||||
* accept seam (orphan-skip) and report the applied-vs-skipped tally. No
|
||||
* confirmation dialog — VS Code undo restores (parity with single accept).
|
||||
* Public so the `cowriting.acceptAllProposals` command can reach it for the
|
||||
* active doc (not only the webview button).
|
||||
*/
|
||||
async acceptAll(document: vscode.TextDocument): Promise<void> {
|
||||
const { applied, skipped } = await this.proposals.acceptAllProposals(document);
|
||||
this.refresh(document);
|
||||
if (applied === 0 && skipped === 0) return;
|
||||
const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : "";
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
|
||||
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
||||
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
|
||||
*/
|
||||
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
|
||||
const instruction = await vscode.window.showInputBox({
|
||||
prompt:
|
||||
target.kind === "document"
|
||||
? "What should Claude do with the document?"
|
||||
: "What should Claude do with the selection?",
|
||||
placeHolder: "e.g. tighten the prose",
|
||||
});
|
||||
if (!instruction) return;
|
||||
try {
|
||||
const ids = await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: "Cowriting: asking Claude…",
|
||||
cancellable: true,
|
||||
},
|
||||
async (progress, token) => {
|
||||
const ui = this.liveProgressUi.begin(instruction, progress, token);
|
||||
try {
|
||||
return await this.runEditAndPropose(document, target, instruction, {
|
||||
onProgress: ui.onProgress,
|
||||
signal: ui.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
// #60 (INV-47): a user cancel proposes nothing (the benign empty path).
|
||||
if (token.isCancellationRequested) return [] as string[];
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
if (ids.length === 0) {
|
||||
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
|
||||
} else {
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: Claude proposed ${ids.length} edit${ids.length === 1 ? "" : "s"} — review ✓/✗ in the preview.`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F11/F12 (INV-35/39): run one host edit turn and record the result as F4
|
||||
* proposal(s) — a SELECTION yields one single-range proposal over the resolved
|
||||
* block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per
|
||||
* changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged
|
||||
* `granularity:"block"` so accept reconciles attribution per word (INV-40).
|
||||
* Never mutates the document (INV-10). Returns the created proposal ids.
|
||||
*/
|
||||
async runEditAndPropose(
|
||||
document: vscode.TextDocument,
|
||||
target: EditTarget,
|
||||
instruction: string,
|
||||
opts?: RunEditTurnOptions,
|
||||
): Promise<string[]> {
|
||||
const full = document.getText();
|
||||
// One turnId per gesture — the document case's N hunk-proposals all share it,
|
||||
// so a single rewrite groups as one agent turn (parity with editSelection).
|
||||
const turnId = `turn-${this.nextTurnSeq()}`;
|
||||
const provenance = (turn: EditTurnResult) =>
|
||||
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
|
||||
if (target.kind === "range") {
|
||||
const selected = full.slice(target.start, target.end);
|
||||
const turn = await this.editTurn(instruction, selected, opts);
|
||||
if (turn.replacement === "" || turn.replacement === selected) return [];
|
||||
const fp = buildFingerprint(full, { start: target.start, end: target.end });
|
||||
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
|
||||
return id ? [id] : [];
|
||||
}
|
||||
const turn = await this.editTurn(instruction, full, opts);
|
||||
const ids: string[] = [];
|
||||
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
|
||||
// granularity — one proposal per changed block (the unit a human reviews) —
|
||||
// not per word. Each is tagged `granularity:"block"` so accept reconciles
|
||||
// attribution per word inside the block (INV-40).
|
||||
for (const h of diffToBlockHunks(full, turn.replacement)) {
|
||||
const fp = buildFingerprint(full, { start: h.start, end: h.end });
|
||||
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), {
|
||||
turnId,
|
||||
instruction,
|
||||
granularity: "block",
|
||||
});
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private onEdit(document: vscode.TextDocument): void {
|
||||
const key = document.uri.toString();
|
||||
if (!this.panels.has(key)) return;
|
||||
const pending = this.debounces.get(key);
|
||||
if (pending) clearTimeout(pending);
|
||||
this.debounces.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
this.debounces.delete(key);
|
||||
this.refresh(document);
|
||||
}, DEBOUNCE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
private refreshByUri(uri: string): void {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (doc) this.refresh(doc);
|
||||
}
|
||||
|
||||
/** Recompute the model + post HTML to the panel for its current mode (no-op if no panel). */
|
||||
refresh(document: vscode.TextDocument): void {
|
||||
const key = document.uri.toString();
|
||||
const panel = this.panels.get(key);
|
||||
if (!panel) return;
|
||||
const mode = this.mode.get(key) ?? "on";
|
||||
const current = document.getText();
|
||||
const baseline = this.diffView.getBaseline(key);
|
||||
const baselineText = baseline?.text ?? current; // no baseline → no change-marks
|
||||
const ops = diffBlocks(baselineText, current);
|
||||
this.lastModel.set(key, ops);
|
||||
// F11 (PUC-1/7): edit controls are inert on a non-authorable doc (reading stays allowed).
|
||||
const authorable = isAuthorable(document.uri.scheme);
|
||||
if (mode === "off") {
|
||||
void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current), authorable });
|
||||
return;
|
||||
}
|
||||
const spans = this.attribution.spansFor(document);
|
||||
const proposals = this.proposals.listProposals(document);
|
||||
const summary = {
|
||||
added: ops.filter((o) => o.kind === "added").length,
|
||||
removed: ops.filter((o) => o.kind === "removed").length,
|
||||
proposals: proposals.length,
|
||||
};
|
||||
void panel.webview.postMessage({
|
||||
type: "render",
|
||||
mode,
|
||||
html: renderReview(baselineText, current, spans, proposals, { pinned: baseline?.reason === "pinned" }),
|
||||
epoch: this.epochLabel(baseline),
|
||||
summary,
|
||||
authorable,
|
||||
});
|
||||
}
|
||||
|
||||
/** F10 (PUC-6): off-panel proposal indicator on the active doc. Hidden when a panel is open. */
|
||||
private updateStatus(uri: string): void {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (!doc) {
|
||||
this.hideStatus();
|
||||
return;
|
||||
}
|
||||
const n = this.proposals.listProposals(doc).length;
|
||||
if (n === 0 || this.panels.has(uri)) {
|
||||
this.hideStatus();
|
||||
return;
|
||||
}
|
||||
this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`;
|
||||
this.statusItem.tooltip = "Cowriting: open the review preview to accept/reject Claude's proposals";
|
||||
this.statusItem.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the off-panel indicator AND clear its text, so the `statusText()` seam
|
||||
* is honest: a hidden indicator reports `undefined` (not its stale last value).
|
||||
*/
|
||||
private hideStatus(): void {
|
||||
this.statusItem.text = "";
|
||||
this.statusItem.hide();
|
||||
}
|
||||
|
||||
private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string {
|
||||
if (!baseline) return "opened (no baseline yet)";
|
||||
const time = new Date(baseline.capturedAt).toLocaleTimeString();
|
||||
switch (baseline.reason) {
|
||||
case "machine-landing":
|
||||
return `Claude landed ${time}`;
|
||||
case "pinned":
|
||||
return `pinned ${time}`;
|
||||
default:
|
||||
return `opened ${time}`;
|
||||
}
|
||||
}
|
||||
|
||||
private shellHtml(webview: vscode.Webview): string {
|
||||
const nonce = randomBytes(16).toString("base64");
|
||||
const scriptUri = webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.js"),
|
||||
);
|
||||
const styleUri = webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.css"),
|
||||
);
|
||||
// Sealed CSP (INV-21): no network. 'unsafe-inline' style is required only for
|
||||
// mermaid's dynamically injected <style> tags; scripts are nonce-gated and
|
||||
// strictly local (no remote/CDN script source).
|
||||
const csp =
|
||||
`default-src 'none'; ` +
|
||||
`img-src ${webview.cspSource} data:; ` +
|
||||
`font-src ${webview.cspSource}; ` +
|
||||
`style-src ${webview.cspSource} 'unsafe-inline'; ` +
|
||||
`script-src 'nonce-${nonce}';`;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}" />
|
||||
<link href="${styleUri}" rel="stylesheet" />
|
||||
<title>Track changes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="cw-header">
|
||||
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
|
||||
<button id="cw-pin" type="button" title="Pin the review baseline to now (clears the change-marks)">⌖ Pin baseline</button>
|
||||
<button id="cw-ask" type="button" title="Ask Claude to edit (the selection if any, else the whole document)">✦ Ask Claude to Edit Document</button>
|
||||
<button id="cw-acceptall" type="button" hidden title="Accept every pending Claude proposal on this document">✓✓ Accept all</button>
|
||||
<span id="cw-epoch">Review</span>
|
||||
<span id="cw-summary"></span>
|
||||
<span id="cw-legend"></span>
|
||||
</div>
|
||||
<div id="cw-body"></div>
|
||||
<script nonce="${nonce}" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ---- test seam (§6.4) ----
|
||||
isOpen(uriString: string): boolean {
|
||||
return this.panels.has(uriString);
|
||||
}
|
||||
/**
|
||||
* F11 test seam: deliver an inbound webview message to the real routing, as if
|
||||
* the sealed webview had posted it. Exercises message→seam wiring without a
|
||||
* live webview DOM (which is manual-smoke only). No-op if no doc/panel.
|
||||
*/
|
||||
receiveMessage(uriString: string, m: ToolbarMsg): void {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m);
|
||||
}
|
||||
/** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */
|
||||
setEditTurnForTest(fn: EditTurn): void {
|
||||
this.editTurn = fn;
|
||||
}
|
||||
/**
|
||||
* F11 (PUC-1/7): whether the previewed doc's edit controls (Pin + Ask-Claude)
|
||||
* are enabled — true only for an authorable doc. The annotations toggle is
|
||||
* always active (reading is always allowed). False if no panel/doc.
|
||||
*/
|
||||
editControlsEnabled(uriString: string): boolean {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
return doc ? isAuthorable(doc.uri.scheme) : false;
|
||||
}
|
||||
getLastModel(uriString: string): BlockOp[] | undefined {
|
||||
return this.lastModel.get(uriString);
|
||||
}
|
||||
/** F10 test seam: the review HTML the panel would post for a doc (on-state). */
|
||||
renderHtmlFor(uriString: string): string {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
if (!doc) return "";
|
||||
const current = doc.getText();
|
||||
const baseline = this.diffView.getBaseline(uriString);
|
||||
return renderReview(
|
||||
baseline?.text ?? current,
|
||||
current,
|
||||
this.attribution.spansFor(doc),
|
||||
this.proposals.listProposals(doc),
|
||||
{ pinned: baseline?.reason === "pinned" },
|
||||
);
|
||||
}
|
||||
/** F10: current annotations mode for a panel (default on). */
|
||||
getMode(uriString: string): "on" | "off" {
|
||||
return this.mode.get(uriString) ?? "on";
|
||||
}
|
||||
/** F10: set the annotations mode and re-render (the programmatic twin of the header toggle). */
|
||||
setMode(uriString: string, mode: "on" | "off"): void {
|
||||
this.mode.set(uriString, mode);
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
if (doc) this.refresh(doc);
|
||||
}
|
||||
/** F10 test seam (SLICE-4 E2E): the off-panel status-bar indicator text, if shown. */
|
||||
statusText(): string | undefined {
|
||||
return this.statusItem.text || undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const t of this.debounces.values()) clearTimeout(t);
|
||||
for (const p of this.panels.values()) p.dispose();
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -60,3 +60,29 @@ export function selectionRejection(ctx: SelectionContext): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface EditRouteContext {
|
||||
/** Was the command invoked on a specific resource (a tab right-click)? */
|
||||
hasUri: boolean;
|
||||
/** Does that resource match the focused editor's document? */
|
||||
uriMatchesActiveEditor: boolean;
|
||||
/** Is there a focused text editor at all? */
|
||||
hasActiveEditor: boolean;
|
||||
/** Is the focused editor's selection empty (no highlight)? */
|
||||
selectionEmpty: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the single "Ask Claude to Edit" gesture (`cowriting.edit`) to the
|
||||
* selection or whole-document flow. One conceptual command, two destinations:
|
||||
*
|
||||
* - A tab right-click on a document that ISN'T the focused editor has no
|
||||
* selection to act on → edit the whole document.
|
||||
* - Otherwise a non-empty selection in the focused editor → edit the selection;
|
||||
* an empty selection (or no editor) → edit the whole document.
|
||||
*/
|
||||
export function routeEdit(ctx: EditRouteContext): "selection" | "document" {
|
||||
if (ctx.hasUri && !ctx.uriMatchesActiveEditor) return "document";
|
||||
if (ctx.hasActiveEditor && !ctx.selectionEmpty) return "selection";
|
||||
return "document";
|
||||
}
|
||||
|
||||
+52
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildFingerprint, resolve, shift, type OffsetRange } from "../src/anchorer";
|
||||
import { buildFingerprint, resolve, shift, shiftTracked, type OffsetRange } from "../src/anchorer";
|
||||
|
||||
const DOC = "line0\nline1 needle here\nline2\nneedle again on line3\n";
|
||||
|
||||
@@ -67,3 +67,54 @@ describe("shift", () => {
|
||||
expect(shift(r, { start: 3, end: 7, newLength: 1 })).toEqual({ start: 3, end: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("shiftTracked (#70 — tracked applied spans)", () => {
|
||||
const r: OffsetRange = { start: 10, end: 20 };
|
||||
|
||||
it("edit entirely before the span shifts both endpoints by the delta", () => {
|
||||
expect(shiftTracked(r, { start: 0, end: 0, newLength: 3 })).toEqual({ start: 13, end: 23 });
|
||||
});
|
||||
|
||||
it("edit entirely after the span leaves it unchanged", () => {
|
||||
expect(shiftTracked(r, { start: 25, end: 28, newLength: 0 })).toEqual({ start: 10, end: 20 });
|
||||
});
|
||||
|
||||
it("edit fully inside the span keeps the start and grows/shrinks the end", () => {
|
||||
// replace [12,15) with 5 chars (delta = +2)
|
||||
expect(shiftTracked(r, { start: 12, end: 15, newLength: 5 })).toEqual({ start: 10, end: 22 });
|
||||
});
|
||||
|
||||
it("replacing the span's exact full contents is an interior edit", () => {
|
||||
expect(shiftTracked(r, { start: 10, end: 20, newLength: 4 })).toEqual({ start: 10, end: 14 });
|
||||
});
|
||||
|
||||
it("an insertion exactly at the span start lands BEFORE the span (never absorbed)", () => {
|
||||
expect(shiftTracked(r, { start: 10, end: 10, newLength: 6 })).toEqual({ start: 16, end: 26 });
|
||||
});
|
||||
|
||||
it("an insertion exactly at the span end lands AFTER the span (never absorbed)", () => {
|
||||
expect(shiftTracked(r, { start: 20, end: 20, newLength: 6 })).toEqual({ start: 10, end: 20 });
|
||||
});
|
||||
|
||||
it("an insertion at an EMPTY span's point lands before it (deletion-proposal span)", () => {
|
||||
const empty: OffsetRange = { start: 10, end: 10 };
|
||||
expect(shiftTracked(empty, { start: 10, end: 10, newLength: 3 })).toEqual({ start: 13, end: 13 });
|
||||
});
|
||||
|
||||
it("an edit straddling the span's start boundary is distrusted (INV-11)", () => {
|
||||
expect(shiftTracked(r, { start: 8, end: 12, newLength: 0 })).toBe("distrusted");
|
||||
});
|
||||
|
||||
it("an edit straddling the span's end boundary is distrusted (INV-11)", () => {
|
||||
expect(shiftTracked(r, { start: 18, end: 22, newLength: 1 })).toBe("distrusted");
|
||||
});
|
||||
|
||||
it("an edit fully containing the span (e.g. a wholesale replace) is distrusted (INV-11)", () => {
|
||||
expect(shiftTracked(r, { start: 0, end: 30, newLength: 12 })).toBe("distrusted");
|
||||
});
|
||||
|
||||
it("a deletion consuming an EMPTY span's neighborhood is distrusted (INV-11)", () => {
|
||||
const empty: OffsetRange = { start: 10, end: 10 };
|
||||
expect(shiftTracked(empty, { start: 8, end: 12, newLength: 0 })).toBe("distrusted");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { BaselineStore, type Baseline } from "../src/baselineStore";
|
||||
import { BaselineStore, normalizeReason, type Baseline } from "../src/baselineStore";
|
||||
|
||||
let dir: string;
|
||||
|
||||
@@ -16,9 +16,23 @@ afterEach(() => {
|
||||
const KEY = "a1b2c3"; // a stand-in for the controller's sha256(uri) key
|
||||
|
||||
function sample(uri = "file:///ws/notes/chapter-1.md"): Baseline {
|
||||
return { uri, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" };
|
||||
return { uri, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "entered" };
|
||||
}
|
||||
|
||||
describe("normalizeReason (INV-7 legacy migration)", () => {
|
||||
it("migrates legacy 'opened' → 'entered'", () => {
|
||||
expect(normalizeReason("opened")).toBe("entered");
|
||||
});
|
||||
it("migrates legacy 'machine-landing' → 'entered'", () => {
|
||||
expect(normalizeReason("machine-landing")).toBe("entered");
|
||||
});
|
||||
it("passes current reasons through unchanged", () => {
|
||||
expect(normalizeReason("entered")).toBe("entered");
|
||||
expect(normalizeReason("pinned")).toBe("pinned");
|
||||
expect(normalizeReason("head")).toBe("head");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaselineStore", () => {
|
||||
it("returns null for a key with no baseline", () => {
|
||||
expect(new BaselineStore(dir).load(KEY)).toBeNull();
|
||||
@@ -36,9 +50,31 @@ describe("BaselineStore", () => {
|
||||
expect(store.load(KEY)).toEqual(b);
|
||||
});
|
||||
|
||||
it("migrates a legacy on-disk reason ('opened') to 'entered' on load", () => {
|
||||
const store = new BaselineStore(dir);
|
||||
fs.mkdirSync(path.dirname(store.baselinePath(KEY)), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
store.baselinePath(KEY),
|
||||
JSON.stringify({ uri: "file:///a.md", text: "legacy\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }),
|
||||
"utf8",
|
||||
);
|
||||
expect(store.load(KEY)?.reason).toBe("entered");
|
||||
});
|
||||
|
||||
it("migrates a legacy on-disk reason ('machine-landing') to 'entered' on load", () => {
|
||||
const store = new BaselineStore(dir);
|
||||
fs.mkdirSync(path.dirname(store.baselinePath(KEY)), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
store.baselinePath(KEY),
|
||||
JSON.stringify({ uri: "file:///a.md", text: "legacy\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "machine-landing" }),
|
||||
"utf8",
|
||||
);
|
||||
expect(store.load(KEY)?.reason).toBe("entered");
|
||||
});
|
||||
|
||||
it("overwrites in place: the newest epoch wins, no history kept", () => {
|
||||
const store = new BaselineStore(dir);
|
||||
store.save(KEY, { uri: "untitled:Untitled-1", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" });
|
||||
store.save(KEY, { uri: "untitled:Untitled-1", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "entered" });
|
||||
store.save(KEY, { uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
|
||||
expect(store.load(KEY)).toEqual({ uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The pure set logic lives in a vscode-free helper the class wraps, so unit-test the class
|
||||
// with a Memento stub (vitest runs vscode-free; the module imports vscode only for types +
|
||||
// EventEmitter — mock it).
|
||||
vi.mock("vscode", () => ({
|
||||
EventEmitter: class {
|
||||
private handlers: Array<(e: unknown) => void> = [];
|
||||
event = (h: (e: unknown) => void) => { this.handlers.push(h); return { dispose() {} }; };
|
||||
fire(e: unknown) { for (const h of this.handlers) h(e); }
|
||||
dispose() {}
|
||||
},
|
||||
Uri: { parse: (s: string) => ({ toString: () => s }) },
|
||||
commands: { executeCommand: vi.fn() },
|
||||
window: { activeTextEditor: undefined },
|
||||
}));
|
||||
import * as vscode from "vscode";
|
||||
import { CoeditingRegistry } from "../src/coeditingRegistry";
|
||||
|
||||
function memento(): { store: Map<string, unknown> } & { get: any; update: any } {
|
||||
const store = new Map<string, unknown>();
|
||||
return {
|
||||
store,
|
||||
get: (k: string, dflt?: unknown) => (store.has(k) ? store.get(k) : dflt),
|
||||
update: (k: string, v: unknown) => { store.set(k, v); return Promise.resolve(); },
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("CoeditingRegistry", () => {
|
||||
it("enter/exit flips membership and fires onDidChange", () => {
|
||||
const reg = new CoeditingRegistry(memento() as any);
|
||||
const events: Array<{ uri: string; coediting: boolean }> = [];
|
||||
reg.onDidChange((e) => events.push(e as any));
|
||||
const uri = { toString: () => "file:///a.md" } as any;
|
||||
expect(reg.isCoediting(uri)).toBe(false);
|
||||
reg.enter(uri);
|
||||
expect(reg.isCoediting(uri)).toBe(true);
|
||||
reg.exit(uri);
|
||||
expect(reg.isCoediting(uri)).toBe(false);
|
||||
expect(events).toEqual([
|
||||
{ uri: "file:///a.md", coediting: true },
|
||||
{ uri: "file:///a.md", coediting: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists the set across construction (reload survival)", () => {
|
||||
const m = memento();
|
||||
const reg1 = new CoeditingRegistry(m as any);
|
||||
reg1.enter({ toString: () => "file:///a.md" } as any);
|
||||
const reg2 = new CoeditingRegistry(m as any);
|
||||
expect(reg2.isCoediting({ toString: () => "file:///a.md" } as any)).toBe(true);
|
||||
expect(reg2.list()).toEqual(["file:///a.md"]);
|
||||
});
|
||||
|
||||
it("enter is idempotent (no duplicate events)", () => {
|
||||
const reg = new CoeditingRegistry(memento() as any);
|
||||
const events: unknown[] = [];
|
||||
reg.onDidChange((e) => events.push(e));
|
||||
const uri = { toString: () => "file:///a.md" } as any;
|
||||
reg.enter(uri);
|
||||
reg.enter(uri);
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("exit is idempotent (no duplicate events on double exit)", () => {
|
||||
const reg = new CoeditingRegistry(memento() as any);
|
||||
const events: unknown[] = [];
|
||||
reg.onDidChange((e) => events.push(e));
|
||||
const uri = { toString: () => "file:///a.md" } as any;
|
||||
reg.enter(uri);
|
||||
reg.exit(uri);
|
||||
reg.exit(uri);
|
||||
expect(events).toHaveLength(2); // enter, then exit (second exit fires nothing)
|
||||
});
|
||||
|
||||
it("syncContext: active editor with coediting doc sets isCoediting=true", () => {
|
||||
const reg = new CoeditingRegistry(memento() as any);
|
||||
const uri = { toString: () => "file:///a.md" } as any;
|
||||
reg.enter(uri);
|
||||
|
||||
const mockExecuteCommand = vscode.commands.executeCommand as any;
|
||||
mockExecuteCommand.mockClear();
|
||||
|
||||
const editor = { document: { uri } } as any;
|
||||
reg.syncContext(editor);
|
||||
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.isCoediting", true);
|
||||
});
|
||||
|
||||
it("syncContext: undefined editor sets isCoediting=false", () => {
|
||||
const reg = new CoeditingRegistry(memento() as any);
|
||||
const mockExecuteCommand = vscode.commands.executeCommand as any;
|
||||
mockExecuteCommand.mockClear();
|
||||
|
||||
reg.syncContext(undefined);
|
||||
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.isCoediting", false);
|
||||
});
|
||||
|
||||
it("syncContext: non-coediting doc sets isCoediting=false", () => {
|
||||
const reg = new CoeditingRegistry(memento() as any);
|
||||
const mockExecuteCommand = vscode.commands.executeCommand as any;
|
||||
mockExecuteCommand.mockClear();
|
||||
|
||||
const uri = { toString: () => "file:///nonexistent.md" } as any;
|
||||
const editor = { document: { uri } } as any;
|
||||
reg.syncContext(editor);
|
||||
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.isCoediting", false);
|
||||
});
|
||||
|
||||
it("syncContext: baselineMode defaults to empty string when not coediting", () => {
|
||||
const reg = new CoeditingRegistry(memento() as any);
|
||||
const mockExecuteCommand = vscode.commands.executeCommand as any;
|
||||
mockExecuteCommand.mockClear();
|
||||
|
||||
const uri = { toString: () => "file:///a.md" } as any;
|
||||
const editor = { document: { uri } } as any;
|
||||
reg.syncContext(editor);
|
||||
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.baselineMode", "");
|
||||
});
|
||||
|
||||
it("syncContext: baselineMode uses hook result when coediting and hook is set", () => {
|
||||
const reg = new CoeditingRegistry(memento() as any);
|
||||
const uri = { toString: () => "file:///a.md" } as any;
|
||||
reg.enter(uri);
|
||||
|
||||
reg.baselineModeOf = vi.fn((): "head" | "snapshot" | undefined => "snapshot");
|
||||
|
||||
const mockExecuteCommand = vscode.commands.executeCommand as any;
|
||||
mockExecuteCommand.mockClear();
|
||||
|
||||
const editor = { document: { uri } } as any;
|
||||
reg.syncContext(editor);
|
||||
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.baselineMode", "snapshot");
|
||||
});
|
||||
|
||||
it("syncContext: baselineMode defaults to empty string when hook returns undefined", () => {
|
||||
const reg = new CoeditingRegistry(memento() as any);
|
||||
const uri = { toString: () => "file:///a.md" } as any;
|
||||
reg.enter(uri);
|
||||
|
||||
reg.baselineModeOf = vi.fn((): "head" | "snapshot" | undefined => undefined);
|
||||
|
||||
const mockExecuteCommand = vscode.commands.executeCommand as any;
|
||||
mockExecuteCommand.mockClear();
|
||||
|
||||
const editor = { document: { uri } } as any;
|
||||
reg.syncContext(editor);
|
||||
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.baselineMode", "");
|
||||
});
|
||||
|
||||
it("syncContext: calls executeCommand twice (isCoediting and baselineMode)", () => {
|
||||
const reg = new CoeditingRegistry(memento() as any);
|
||||
const uri = { toString: () => "file:///a.md" } as any;
|
||||
reg.enter(uri);
|
||||
|
||||
const mockExecuteCommand = vscode.commands.executeCommand as any;
|
||||
mockExecuteCommand.mockClear();
|
||||
|
||||
const editor = { document: { uri } } as any;
|
||||
reg.syncContext(editor);
|
||||
|
||||
expect(mockExecuteCommand).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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-"));
|
||||
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 {
|
||||
await runTests({
|
||||
extensionDevelopmentPath,
|
||||
extensionTestsPath,
|
||||
launchArgs: [workspace, "--disable-extensions"],
|
||||
launchArgs: [workspace, "--disable-extensions", "--user-data-dir", userDataDir],
|
||||
extensionTestsEnv: { E2E_WORKSPACE: workspace },
|
||||
});
|
||||
|
||||
@@ -26,10 +35,11 @@ async function main(): Promise<void> {
|
||||
await runTests({
|
||||
extensionDevelopmentPath,
|
||||
extensionTestsPath: path.resolve(__dirname, "./suite-no-workspace/index"),
|
||||
launchArgs: ["--disable-extensions"],
|
||||
launchArgs: ["--disable-extensions", "--user-data-dir", userDataDir],
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { settleUntil } from "../suite/helpers";
|
||||
|
||||
/**
|
||||
* Task 9 (spec §7.1 rung 3 — the real @cline/sdk, manual gate). Drives the
|
||||
* SAME comments-first ask -> reply -> offer -> proposal loop as
|
||||
* test/e2e/suite/commentLoop.test.ts / fullLoop.test.ts, but with BOTH turns
|
||||
* left UNSTUBBED: ThreadController's reply loop and EditFlow's edit turn hit
|
||||
* the real local `claude` login (INV-8 -- the extension itself holds no
|
||||
* credentials). Not part of `npm run test:e2e` / CI -- driven only by
|
||||
* `scripts/smoke-native-loop.mjs` (`npm run smoke:native`), mirroring
|
||||
* `scripts/smoke-live-turn.mjs`'s "manual gate, real SDK" role for F3.
|
||||
*
|
||||
* There is no VS Code API to drive the native comment-widget's OWN submit
|
||||
* gesture from outside the UI (see commentLoop.test.ts's "Finding 1" note) --
|
||||
* this calls the exact seam a real comment submission reaches
|
||||
* (ThreadController.createThreadOnSelection / .makeThreadEdit), which is the
|
||||
* closest a headless script gets to "post a real comment" on a live doc.
|
||||
*/
|
||||
export async function run(): Promise<void> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin");
|
||||
if (!ext) throw new Error("extension not found -- is extensionDevelopmentPath wired correctly?");
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
if (!api?.threadController || !api?.editFlow || !api?.proposalController || !api?.sidecarRouter) {
|
||||
throw new Error("extension did not export the expected controllers");
|
||||
}
|
||||
|
||||
const folder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!folder) throw new Error("no workspace folder -- launch with the sandbox/ folder open");
|
||||
const target = path.join(folder.uri.fsPath, `.smoke-native-loop-${process.pid}.md`);
|
||||
const ORIGINAL = "The quick brown fox jumps over the lazy dog paragraph.";
|
||||
fs.writeFileSync(target, `# Native-loop smoke\n\n${ORIGINAL}\n`, "utf8");
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(target);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
const ed = await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
|
||||
const docKey = api.proposalController.keyFor(doc);
|
||||
const start = doc.getText().indexOf(ORIGINAL);
|
||||
ed.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + ORIGINAL.length));
|
||||
|
||||
console.log("[smoke-native-loop] posting a real comment (createThreadOnSelection)...");
|
||||
const threadId = await api.threadController.createThreadOnSelection(
|
||||
"Rewrite this sentence to be more concise.",
|
||||
);
|
||||
if (!threadId) throw new Error("createThreadOnSelection refused (not coediting, or no selection)");
|
||||
console.log(`[smoke-native-loop] thread created: ${threadId} -- waiting for the real @cline/sdk reply (this calls the live SDK; may take a while)...`);
|
||||
|
||||
await settleUntil(() => {
|
||||
const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === threadId);
|
||||
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
|
||||
}, 180000);
|
||||
console.log("[smoke-native-loop] reply landed -- thread flipped to \"offer\". Making the edit (real SDK)...");
|
||||
|
||||
const ids = await api.threadController.makeThreadEdit(threadId, docKey);
|
||||
if (ids.length === 0) throw new Error("makeThreadEdit produced no proposals (the turn returned an empty/no-op replacement)");
|
||||
console.log(`[smoke-native-loop] proposal id(s) created: ${ids.join(", ")}`);
|
||||
|
||||
await settleUntil(() => ids.every((id) => api.proposalController.isApplied(docKey, id)), 10000);
|
||||
for (const id of ids) {
|
||||
const ok = await api.proposalController.finalizeInPlace(docKey, id);
|
||||
console.log(`[smoke-native-loop] accepted proposal ${id}: ${ok}`);
|
||||
}
|
||||
console.log(`[smoke-native-loop] PASS -- ${ids.length} proposal(s) created and accepted: ${ids.join(", ")}`);
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(target, { force: true });
|
||||
} catch {
|
||||
// best-effort cleanup -- never let cleanup mask the real result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
|
||||
"cowriting.reply",
|
||||
"cowriting.resolveThread",
|
||||
"cowriting.reopenThread",
|
||||
"cowriting.edit",
|
||||
"cowriting.editSelection",
|
||||
"cowriting.applyAgentEdit",
|
||||
"cowriting.proposeAgentEdit",
|
||||
@@ -78,22 +79,25 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
|
||||
|
||||
// F6 (#19) baseline data layer is workspace-INDEPENDENT: it captures a baseline
|
||||
// for an untitled buffer even with no folder open (the two-pane VIEW was
|
||||
// removed in #34; only the data layer remains). pinDiffBaseline stays real.
|
||||
// removed in #34; only the data layer remains). markReviewed stays real.
|
||||
// Native-surfaces migration (Task 2, INV-7): the baseline is established on
|
||||
// coediting ENTRY, not merely on open.
|
||||
test("F6 baseline data layer works with no folder open (untitled buffer)", async () => {
|
||||
const all = await vscode.commands.getCommands(true);
|
||||
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline registered");
|
||||
assert.ok(all.includes("cowriting.markReviewed"), "markReviewed registered");
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
const untitled = await vscode.workspace.openTextDocument({ content: "no-folder scratch\n", language: "markdown" });
|
||||
await vscode.window.showTextDocument(untitled);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const key = untitled.uri.toString();
|
||||
const baseline = api.diffViewController.getBaseline(key);
|
||||
assert.ok(baseline, "baseline captured for the untitled buffer with no folder");
|
||||
assert.strictEqual(baseline!.reason, "opened");
|
||||
// pin resets the baseline to now — works folder-less.
|
||||
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
|
||||
assert.strictEqual(baseline!.reason, "entered");
|
||||
// markReviewed resets the baseline to now — works folder-less.
|
||||
await vscode.commands.executeCommand("cowriting.markReviewed");
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "pin works with no folder");
|
||||
assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "markReviewed works with no folder");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,9 @@ async function openDoc(): Promise<vscode.TextDocument> {
|
||||
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: attribution tracking is gated on coediting (Task 4) — idempotent
|
||||
// across this order-dependent suite's repeated openDoc() calls.
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
return doc;
|
||||
}
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { renderHtmlFor } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 300));
|
||||
@@ -10,38 +11,40 @@ const settle = () => new Promise((r) => setTimeout(r, 300));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal");
|
||||
assert.ok(api?.attributionController && api?.proposalController, "exports attribution + proposal");
|
||||
return api;
|
||||
}
|
||||
|
||||
// F10 host E2E (no LLM): the rewrite of the obsolete F9 authorship-mode test.
|
||||
// F9's "authorship" mode / renderAuthorship is gone — the on-state renderReview
|
||||
// now author-colors Claude's landed prose. This suite confirms a Claude-landed
|
||||
// span renders as a cw-by-claude span in the on-state preview HTML. Owns its own
|
||||
// markdown doc, disjoint from the other suites' fixtures.
|
||||
suite("F10 review preview — Claude-authored prose is cw-by-claude in the on-state (host E2E, no LLM)", () => {
|
||||
// now author-colors Claude's PENDING proposal blocks as cw-ins-claude. This
|
||||
// suite checks that render BEFORE accepting (the class to check is cw-ins-claude,
|
||||
// the proposal block, NOT cw-by-claude — the old F9 authorship render) and that
|
||||
// the attribution data layer survives the accept; it does not exercise the F6
|
||||
// baseline (see diffView.test.ts / baselineRouter.test.ts for the post-accept
|
||||
// baseline behavior, INV-7/D21). Owns its own markdown doc. Task 8: the render
|
||||
// probe is now the pure `renderReview` directly (the webview that used to wrap
|
||||
// it, `renderHtmlFor`, is gone).
|
||||
suite("F10 review preview — pending Claude proposal renders cw-ins-claude in the on-state (host E2E, no LLM)", () => {
|
||||
const DOC_REL = "docs/f10claude.md";
|
||||
const TARGET = "The sentence Claude will compose over.";
|
||||
const REPLACEMENT = "The sentence CLAUDE COMPOSED via the seam.";
|
||||
|
||||
test("an accepted Claude edit author-colors as cw-by-claude in the on-state render; mode defaults to on", async () => {
|
||||
test("a pending Claude proposal carries cw-ins-claude; accepted proposal leaves attribution data intact", async () => {
|
||||
const abs = path.join(WS, DOC_REL);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, `# F10\n\n${TARGET}\n`, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: entering coediting is required for attribution tracking + F12
|
||||
// optimistic-apply to fire — this suite never entered it (pre-migration).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
const key = uri.toString();
|
||||
|
||||
// open the preview — annotations default ON (F10/INV-33)
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open");
|
||||
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default to on");
|
||||
|
||||
// Claude composes via the seam (propose → accept)
|
||||
// Claude proposes via the seam (F12 optimistic-apply; proposal stays PENDING)
|
||||
const start = doc.getText().indexOf(TARGET);
|
||||
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||
uri: key,
|
||||
@@ -52,17 +55,28 @@ suite("F10 review preview — Claude-authored prose is cw-by-claude in the on-st
|
||||
sessionId: "e2e-f10",
|
||||
turnId: "turn-f10",
|
||||
});
|
||||
assert.ok(id, "propose returns an id");
|
||||
await settle();
|
||||
|
||||
// PENDING proposal: the preview renders the proposal block with cw-ins-claude.
|
||||
// After accepting, the baseline advances (INV-18) → the text becomes "unchanged"
|
||||
// and renders plain — so we check BEFORE accepting.
|
||||
const pendingHtml = renderHtmlFor(api, doc, key);
|
||||
assert.ok(
|
||||
pendingHtml.includes("cw-ins-claude"),
|
||||
"pending Claude proposal block carries cw-ins-claude in the on-state render",
|
||||
);
|
||||
|
||||
// accept via the seam — the proposal lands; the baseline no longer auto-advances
|
||||
// (INV-18/#48 retired, spec INV-7), but that isn't asserted here (no coediting
|
||||
// entry / baseline established for this doc — only the attribution data layer).
|
||||
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
|
||||
await settle();
|
||||
|
||||
// attribution recorded a Claude (agent) span (data layer intact)
|
||||
const claudeSpan = api.attributionController.getSpans(DOC_REL).find((s) => s.authorKind === "agent");
|
||||
assert.ok(claudeSpan, "Claude span recorded by F3");
|
||||
assert.ok(claudeSpan, "Claude span recorded by F3 (data layer intact after accept)");
|
||||
const spans = api.attributionController.spansFor(doc);
|
||||
assert.ok(spans.some((s) => s.author === "claude"), "spansFor reports a Claude span for the preview");
|
||||
|
||||
// the on-state render author-colors the landed Claude text as cw-by-claude
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
assert.match(html, /<span class="cw-by-claude">/, "landed Claude prose is author-colored in the on-state render");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as assert from "node:assert";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
import { activateApi, settle, settleUntil } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
|
||||
suite("baseline router (PUC-1, INV-7/D13/D14)", () => {
|
||||
test("snapshot mode: enter captures; markReviewed re-pins clean", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "one\n" });
|
||||
const ed = await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
assert.strictEqual(api.diffViewController.modeOf(doc.uri.toString()), "snapshot");
|
||||
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "one\n");
|
||||
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "two\n"));
|
||||
// Task 3 (INV-13): the status-bar/SCM change count tracks the live edit.
|
||||
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 1);
|
||||
await vscode.commands.executeCommand("cowriting.markReviewed");
|
||||
const b = api.diffViewController.getBaseline(doc.uri.toString());
|
||||
assert.strictEqual(b?.text, "one\ntwo\n");
|
||||
assert.strictEqual(b?.reason, "pinned");
|
||||
// A pin re-baselines to the clean current text, so the change count resets.
|
||||
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 0);
|
||||
});
|
||||
|
||||
test("head mode: baseline = HEAD; commit advances it", async function () {
|
||||
this.timeout(30000);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-git-"));
|
||||
const git = (...a: string[]) => execFileSync("git", ["-C", dir, ...a], { encoding: "utf8" });
|
||||
git("init", "-q");
|
||||
fs.writeFileSync(path.join(dir, "doc.md"), "committed\n");
|
||||
git("add", "doc.md");
|
||||
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "c1");
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(path.join(dir, "doc.md")));
|
||||
const ed = await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settleUntil(() => api.diffViewController.modeOf(doc.uri.toString()) === "head", 10000);
|
||||
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "committed\n");
|
||||
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "uncommitted\n"));
|
||||
await doc.save();
|
||||
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-aqm", "c2");
|
||||
await settleUntil(
|
||||
() => api.diffViewController.getBaseline(doc.uri.toString())?.text === "committed\nuncommitted\n",
|
||||
15000,
|
||||
);
|
||||
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.reason, "head");
|
||||
});
|
||||
|
||||
// Finding 3 (final whole-branch review, session native-surfaces-exec):
|
||||
// DiffViewController only establishes a baseline on the registry's ENTER
|
||||
// transition (a doc it can find already open) and, at startup, for docs
|
||||
// already open at that moment. A registry member entered while its document
|
||||
// was NOT yet open (the real-world case: a persisted coediting set restored
|
||||
// after a window reload, whose member is only lazily opened later) used to
|
||||
// never get a baseline at all — reproduced here directly via
|
||||
// `coeditingRegistry.enter(uri)` on a URI with no open document yet (the
|
||||
// registry API doesn't require one), then a genuine later open.
|
||||
test("a registry member entered before its doc was open gets its baseline established once the doc IS opened", async () => {
|
||||
const api = await activateApi();
|
||||
const rel = "docs/finding3-establish-on-open.md";
|
||||
const abs = path.join(WS, rel);
|
||||
const content = "# late open\n\nestablish-on-open should catch this.\n";
|
||||
fs.writeFileSync(abs, content, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
|
||||
// Membership without an open document — DiffViewController's enter-
|
||||
// transition handler no-ops (`textDocuments.find` misses), reproducing
|
||||
// the gap: a coediting member with no mode/baseline yet.
|
||||
api.coeditingRegistry.enter(uri);
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), true, "sanity: registry membership recorded");
|
||||
assert.strictEqual(api.diffViewController.modeOf(uri.toString()), undefined, "sanity: the gap is reproduced");
|
||||
|
||||
// A genuine later open — the fix's `renderIfOpen`/`onDidOpenTextDocument`
|
||||
// path must establish the baseline it missed.
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settleUntil(() => api.diffViewController.modeOf(uri.toString()) !== undefined, 10000);
|
||||
assert.strictEqual(api.diffViewController.modeOf(uri.toString()), "snapshot");
|
||||
assert.strictEqual(api.diffViewController.getBaseline(uri.toString())?.text, content);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import * as assert from "node:assert";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import * as vscode from "vscode";
|
||||
import { cowritingMarkdownItPlugin } from "../../../src/previewAnnotations";
|
||||
import { activateApi, settle, settleUntil } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
|
||||
async function api() {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
return (await ext.activate()) as import("../../../src/extension").CowritingApi;
|
||||
}
|
||||
|
||||
suite("coediting registry (PUC-7)", () => {
|
||||
test("enter → isCoediting true; exit → false; persists in list()", async () => {
|
||||
const { coeditingRegistry } = await api();
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# t\n\nbody\n" });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), true);
|
||||
assert.ok(coeditingRegistry.list().includes(doc.uri.toString()));
|
||||
await vscode.commands.executeCommand("cowriting.stopCoediting");
|
||||
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), false);
|
||||
});
|
||||
|
||||
// Task 3 (INV-10): a document that was never entered into coediting gets no
|
||||
// native-diff surface at all. QuickDiff's `provideOriginalResource` isn't
|
||||
// directly queryable from a host-E2E test, so this asserts the gate INPUTS
|
||||
// structurally: `isCoediting` stays false and the SCM change count — which
|
||||
// `ScmSurfaceController` only ever populates for a coediting doc — stays 0.
|
||||
test("non-entered doc: no coediting, no change count (structural, INV-10)", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "untouched\n" });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(doc.uri), false);
|
||||
assert.strictEqual(api.scmSurfaceController.changeCount(doc.uri.toString()), 0);
|
||||
});
|
||||
|
||||
// Task 4 (INV-10, PUC-7): the no-hijack scenario — a non-entered doc gets no
|
||||
// thread-creation surface at all; entering unlocks it; exiting hides the
|
||||
// rendered thread (not the sidecar-backed data); re-entering restores it.
|
||||
test("non-entered doc gets no surfaces; exit detaches; re-enter restores threads", async () => {
|
||||
const api = await activateApi();
|
||||
// Task 6 (D10): createThreadOnSelection now also fires the respond-in-
|
||||
// thread turn once the doc is coediting — stub it off (not under test
|
||||
// here; this suite asserts render/hide/restore, not the reply loop).
|
||||
api.threadController.setTurnRunnerForTest(async () => {
|
||||
throw new Error("coediting suite: reply-loop turn stubbed off");
|
||||
});
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# a\n\npara\n" });
|
||||
const ed = await vscode.window.showTextDocument(doc);
|
||||
// not entered → no thread creation
|
||||
ed.selection = new vscode.Selection(2, 0, 2, 4);
|
||||
const before = await api.threadController.createThreadOnSelection("hi");
|
||||
assert.strictEqual(before, undefined);
|
||||
// entered → works
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
const id = await api.threadController.createThreadOnSelection("hi");
|
||||
assert.ok(id);
|
||||
// exit → rendered thread set empty; re-enter → restored from sidecar
|
||||
await vscode.commands.executeCommand("cowriting.stopCoediting");
|
||||
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 0);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 1);
|
||||
});
|
||||
|
||||
// PUC-7 restore-on-enter gap: unlike ThreadController (Task 4, above),
|
||||
// ProposalController.renderAll and AttributionController.loadAll were never
|
||||
// wired to registry.onDidChange — so a document's FIRST enter into
|
||||
// coediting with pre-existing sidecar proposals/attribution showed threads
|
||||
// but not proposal decorations/CodeLens or committed author-coloring until
|
||||
// a later edit happened to fire renderAll/loadAll. A same-session
|
||||
// exit-then-re-enter doesn't reproduce this on its own (nothing clears the
|
||||
// in-memory render caches on exit), so this test wipes those caches after
|
||||
// exit — simulating "never rendered in this session yet" — then re-enters
|
||||
// and asserts BOTH surfaces restore via the registry.onDidChange path
|
||||
// alone, with no subsequent edit.
|
||||
test("re-enter after exit restores proposal + attribution surfaces without an edit (PUC-7)", async () => {
|
||||
const api = await activateApi();
|
||||
// A workspace FILE (not untitled) — attribution only persists to the
|
||||
// sidecar on save, and this test needs the sidecar (not just in-memory
|
||||
// live state) to hold the seeded data before re-entering.
|
||||
const rel = "docs/puc7-restore.md";
|
||||
const abs = path.join(WS, rel);
|
||||
fs.writeFileSync(abs, "# t\n\nfoo bar baz\n", "utf8");
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
|
||||
// Seed a pending proposal (F4) + a committed agent-attributed edit (F3) —
|
||||
// the two surfaces the gap left un-restored.
|
||||
const target = "bar";
|
||||
const start = doc.getText().indexOf(target);
|
||||
const proposalId = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||
uri: doc.uri.toString(),
|
||||
start,
|
||||
end: start + target.length,
|
||||
newText: "BAR-PROPOSED",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-puc7",
|
||||
turnId: "turn-puc7",
|
||||
});
|
||||
assert.ok(proposalId, "propose returns an id");
|
||||
// A real text change (not a no-op replace) — "foo" precedes the "bar"
|
||||
// target above, so it is unaffected by F12's optimistic-apply of the
|
||||
// proposal into the buffer.
|
||||
const fooStart = doc.getText().indexOf("foo");
|
||||
const applied = await api.attributionController.applyAgentEdit(
|
||||
doc,
|
||||
new vscode.Range(doc.positionAt(fooStart), doc.positionAt(fooStart + "foo".length)),
|
||||
"FOO-CLAUDE",
|
||||
{ kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-puc7" } },
|
||||
{ turnId: "turn-puc7" },
|
||||
);
|
||||
assert.strictEqual(applied, true, "seam edit applies");
|
||||
await settle();
|
||||
await doc.save(); // persists attribution records to the sidecar (F3 PUC-4)
|
||||
|
||||
const key = api.proposalController.keyFor(doc);
|
||||
assert.ok(api.proposalController.getRendered(key).length >= 1, "sanity: proposal rendered pre-exit");
|
||||
assert.ok(api.attributionController.getSpans(key).length >= 1, "sanity: attribution rendered pre-exit");
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.stopCoediting");
|
||||
|
||||
// Wipe the controllers' in-memory render caches for this doc — nothing in
|
||||
// production does this on exit (by design: exit hides, it does not wipe
|
||||
// sidecar-backed state), so this reflection reproduces the "never
|
||||
// rendered this session" precondition of a genuinely fresh first-enter.
|
||||
(api.proposalController as unknown as { docs: Map<string, unknown> }).docs.delete(key);
|
||||
(api.attributionController as unknown as { docs: Map<string, unknown> }).docs.delete(key);
|
||||
assert.strictEqual(api.proposalController.getRendered(key).length, 0, "cache cleared pre-re-enter");
|
||||
assert.strictEqual(api.attributionController.getSpans(key).length, 0, "cache cleared pre-re-enter");
|
||||
|
||||
// Re-enter — the registry.onDidChange subscribers (PUC-7 fix) must
|
||||
// restore both surfaces from the sidecar with no subsequent edit.
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settleUntil(
|
||||
() => api.proposalController.getRendered(key).length >= 1 && api.attributionController.getSpans(key).length >= 1,
|
||||
);
|
||||
assert.strictEqual(api.proposalController.getRendered(key).length, 1, "proposal restored on re-enter");
|
||||
assert.ok(api.attributionController.getSpans(key).length >= 1, "attribution restored on re-enter");
|
||||
});
|
||||
|
||||
// Finding 1 (final whole-branch review, session native-surfaces-exec): the
|
||||
// preview-annotation host's INV-10 gate must never leak one document's
|
||||
// baseline/spans into another document's preview. The built-in preview's
|
||||
// rendered DOM isn't queryable from a host E2E test (§6.8, mirrored from
|
||||
// Task 7's own previewAnnotations.test.ts), so this drives the ACTUAL
|
||||
// production `previewAnnotationHost` through a real markdown-it instance
|
||||
// and asserts on the rendered HTML.
|
||||
test("INV-10: a non-coedited doc's preview never inherits another doc's annotations", async () => {
|
||||
const api = await activateApi();
|
||||
const docA = await vscode.workspace.openTextDocument({ language: "markdown", content: "alpha original\n" });
|
||||
await vscode.window.showTextDocument(docA);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
const start = docA.getText().indexOf("original");
|
||||
const okA = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
|
||||
uri: docA.uri.toString(),
|
||||
start,
|
||||
end: start + "original".length,
|
||||
newText: "REPLACED",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-inv10-leak",
|
||||
turnId: "turn-e2e-inv10-leak",
|
||||
});
|
||||
assert.strictEqual(okA, true, "seam edit applies to docA");
|
||||
await settle();
|
||||
|
||||
// docB is opened (becomes the active editor, so it would win any
|
||||
// `lastCoeditedUri` fallback if the gate were wrong) but NEVER entered
|
||||
// into coediting.
|
||||
const docB = await vscode.workspace.openTextDocument({ language: "markdown", content: "bravo untouched\n" });
|
||||
await vscode.window.showTextDocument(docB);
|
||||
await settle();
|
||||
|
||||
const md = new MarkdownIt();
|
||||
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
|
||||
const html = md.render(docB.getText(), { currentDocument: docB.uri });
|
||||
assert.ok(!html.includes("cw-ins-claude"), `docB must render with no annotations, got:\n${html}`);
|
||||
assert.ok(!html.includes("original"), `docA's baseline text must never leak into docB's preview:\n${html}`);
|
||||
assert.ok(html.includes("bravo untouched"), "docB renders its own content unannotated");
|
||||
});
|
||||
|
||||
// Finding 1, failure mode (b): `lastCoeditedUri` is never re-validated
|
||||
// against the registry, so a doc's OWN preview stayed annotated after
|
||||
// `stopCoediting` — an INV-10 violation for the doc whose gate was just
|
||||
// closed, not just for a different doc.
|
||||
test("INV-10: stopCoediting immediately clears a doc's own preview annotations", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "gamma original\n" });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
const start = doc.getText().indexOf("original");
|
||||
const ok = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
|
||||
uri: doc.uri.toString(),
|
||||
start,
|
||||
end: start + "original".length,
|
||||
newText: "REPLACED",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-inv10-stop",
|
||||
turnId: "turn-e2e-inv10-stop",
|
||||
});
|
||||
assert.strictEqual(ok, true, "seam edit applies");
|
||||
await settle();
|
||||
|
||||
const md = new MarkdownIt();
|
||||
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
|
||||
const before = md.render(doc.getText(), { currentDocument: doc.uri });
|
||||
assert.ok(before.includes("cw-ins-claude"), `sanity: annotated while coediting, got:\n${before}`);
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.stopCoediting");
|
||||
await settle();
|
||||
const after = md.render(doc.getText(), { currentDocument: doc.uri });
|
||||
assert.ok(!after.includes("cw-ins-claude"), `expected a clean render after stopCoediting, got:\n${after}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as assert from "assert";
|
||||
import * as vscode from "vscode";
|
||||
import { activateApi, settleUntil } from "./helpers";
|
||||
|
||||
// Task 6 (D19/D10/D8, PUC-8): the comments-first ask + comment→reply→offer→
|
||||
// proposal loop, with a stubbed reply turn (no real @cline/sdk call). Runs on
|
||||
// an untitled buffer (F8 routes it to the global sidecar) so this suite has no
|
||||
// workspace-folder dependency.
|
||||
suite("comment → reply → offer → proposal (PUC-8, D10/D19)", () => {
|
||||
test("reply on a coedited doc summons a machine reply + offer; accept lands pending proposals", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
language: "markdown",
|
||||
content: "# T\n\nThe quick brown fox jumps over the lazy dog paragraph.\n",
|
||||
});
|
||||
const ed = await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
api.threadController.setTurnRunnerForTest(async () => ({
|
||||
replacement: "I would tighten this sentence.",
|
||||
model: "stub",
|
||||
sessionId: "s1",
|
||||
}));
|
||||
api.editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "The quick fox jumps the lazy dog.",
|
||||
model: "stub",
|
||||
sessionId: "s1",
|
||||
}));
|
||||
ed.selection = new vscode.Selection(2, 0, 2, 20);
|
||||
const threadId = (await api.threadController.createThreadOnSelection("tighten this"))!;
|
||||
// reply-loop fires on the human message; wait for the machine message to persist
|
||||
await settleUntil(() => {
|
||||
const t = api.sidecarRouter.load(api.proposalController.keyFor(doc))?.threads.find((x) => x.id === threadId);
|
||||
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
|
||||
}, 10000);
|
||||
// Finding 2 (final whole-branch review): the machine reply flips the
|
||||
// thread into an "offer" — this used to CLOBBER the "open" status token
|
||||
// instead of folding in alongside it, so Resolve/Reopen disappeared from
|
||||
// the menu the moment any thread got a reply. Assert both tokens survive
|
||||
// together, directly via the rendered vsThread's contextValue (the exact
|
||||
// string package.json's `comments/commentThread/title` `when`-clauses key
|
||||
// on) — no DOM/UI query needed.
|
||||
const docKey = api.proposalController.keyFor(doc);
|
||||
const afterReply = api.threadController.getRendered(docKey).find((t) => t.id === threadId);
|
||||
assert.ok(afterReply, "thread is rendered after the reply lands");
|
||||
assert.match(
|
||||
afterReply!.contextValue,
|
||||
/\bopen\b/,
|
||||
`expected the "open" status token to survive the offer transition, got "${afterReply!.contextValue}"`,
|
||||
);
|
||||
assert.match(
|
||||
afterReply!.contextValue,
|
||||
/\boffer\b/,
|
||||
`expected the "offer" token once the machine reply lands, got "${afterReply!.contextValue}"`,
|
||||
);
|
||||
const ids = await api.threadController.makeThreadEdit(threadId, api.proposalController.keyFor(doc));
|
||||
assert.ok(ids.length >= 1);
|
||||
assert.ok(api.proposalController.listProposals(doc).length >= 1); // pending, INV-5
|
||||
// Spending the offer flips it to "offerdone" — status token still intact,
|
||||
// and the plain "offer" token must not still match (it's a DIFFERENT word,
|
||||
// not just a substring: /\boffer\b/ must not match "offerdone").
|
||||
const afterMakeEdit = api.threadController.getRendered(docKey).find((t) => t.id === threadId);
|
||||
assert.match(afterMakeEdit!.contextValue, /\bopen\b/, "status token still intact after spending the offer");
|
||||
assert.match(afterMakeEdit!.contextValue, /\bofferdone\b/, "offer token flips to offerdone once spent");
|
||||
assert.doesNotMatch(afterMakeEdit!.contextValue, /\boffer\b(?!done)/, "the bare offer token is gone once spent");
|
||||
});
|
||||
|
||||
test("a comment on a NON-coedited doc summons nothing (INV-10)", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "plain\n" });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
const id = await api.threadController.createThreadOnSelection("hello");
|
||||
assert.strictEqual(id, undefined); // gate refuses thread creation entirely
|
||||
});
|
||||
|
||||
// Finding 1 (code review, session native-surfaces-exec): `cowriting.edit` with NO
|
||||
// selection used to route through `cowriting.editDocument` → the removed
|
||||
// `askEditInstruction` webview prompt — a rejecting stub `void`'d before any
|
||||
// try/catch, i.e. a completely silent dead end (no toast, no thread). It must now
|
||||
// reach ThreadController.askClaude()'s top-anchored whole-document path (D19) —
|
||||
// the same comments-first ask the selection route already used. There is no API to
|
||||
// drive VS Code's native comment-widget submission from a host E2E test (the actual
|
||||
// comment→reply→offer→proposal round trip is exercised above via the
|
||||
// createThreadOnSelection seam), so this asserts the routing itself: the gesture
|
||||
// reaches ThreadController.askClaude (never the old broken stub) and does not throw.
|
||||
test("cowriting.edit with no selection on a coedited doc reaches the top-anchored ask path, not a silent dead end", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
language: "markdown",
|
||||
content: "# T\n\nA document-level ask with no selection.\n",
|
||||
});
|
||||
const ed = await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
// Defensive stubs, mirroring the suite's other test — no real turn should fire
|
||||
// from this gesture alone (no comment text is ever submitted), but guard against
|
||||
// a real @cline/sdk call if the routing regresses.
|
||||
api.threadController.setTurnRunnerForTest(async () => ({ replacement: "stub", model: "stub", sessionId: "s1" }));
|
||||
api.editFlow.setEditTurnForTest(async () => ({ replacement: "stub", model: "stub", sessionId: "s1" }));
|
||||
ed.selection = new vscode.Selection(0, 0, 0, 0); // no selection
|
||||
|
||||
let askClaudeCalls = 0;
|
||||
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
|
||||
api.threadController.askClaude = async () => {
|
||||
askClaudeCalls++;
|
||||
return origAskClaude();
|
||||
};
|
||||
try {
|
||||
await vscode.commands.executeCommand("cowriting.edit");
|
||||
} finally {
|
||||
api.threadController.askClaude = origAskClaude;
|
||||
}
|
||||
assert.strictEqual(
|
||||
askClaudeCalls,
|
||||
1,
|
||||
"cowriting.edit (no selection) reached ThreadController.askClaude — not the removed editDocument prompt stub",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@ async function open(docRel: string): Promise<vscode.TextDocument> {
|
||||
const uri = vscode.Uri.file(path.join(WS, docRel));
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: thread creation/rendering is gated on coediting (Task 4).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
return doc;
|
||||
}
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
@@ -55,6 +57,13 @@ suite("F5 cross-rung round-trip (host E2E)", () => {
|
||||
const DOC = "docs/crossrung.md";
|
||||
const doc = await open(DOC);
|
||||
const api = await getApi();
|
||||
// Task 6 (D10): createThreadOnSelection also fires the respond-in-thread
|
||||
// turn now — stub it off so it can't race the forge stand-in's reply
|
||||
// below (this test asserts an EXACT 2-message sequence: editor note +
|
||||
// forge reply).
|
||||
api.threadController.setTurnRunnerForTest(async () => {
|
||||
throw new Error("crossrung suite: reply-loop turn stubbed off");
|
||||
});
|
||||
const target = "portable record";
|
||||
const start = doc.getText().indexOf(target);
|
||||
const editor = vscode.window.activeTextEditor!;
|
||||
|
||||
@@ -15,6 +15,16 @@ async function openDoc(): Promise<vscode.TextDocument> {
|
||||
await vscode.window.showTextDocument(doc);
|
||||
return doc;
|
||||
}
|
||||
/** Open the doc AND enter coediting (INV-10) — baselines are established only
|
||||
* on entry (Task 2). Idempotent across tests: re-entering an already-coedited
|
||||
* doc is a no-op, so the once-captured baseline survives (order-dependent
|
||||
* suite, same as before). */
|
||||
async function openAndCoedit(): Promise<vscode.TextDocument> {
|
||||
const doc = await openDoc();
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return doc;
|
||||
}
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
@@ -29,22 +39,29 @@ const settle = () => new Promise((r) => setTimeout(r, 300));
|
||||
// later tests consume earlier state. Owns docs/diffview.md exclusively. The
|
||||
// baseline works on ANY file (#19), so the last two tests use an out-of-workspace
|
||||
// file and an untitled buffer.
|
||||
//
|
||||
// Native-surfaces migration (Task 2, spec §6.4/INV-7): a baseline is now
|
||||
// established only once a document ENTERS coediting (not merely opened), and
|
||||
// the fixture workspace is NOT a git repo, so every doc here resolves to
|
||||
// SNAPSHOT mode. The shipped machine-landing baseline advance (#48/INV-18) is
|
||||
// retired — an accepted proposal now stays a visible change-since-baseline
|
||||
// until "Mark Changes as Reviewed" (D21).
|
||||
suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, no LLM)", () => {
|
||||
const TARGET = "A target sentence Claude will rewrite via the seam.";
|
||||
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
|
||||
|
||||
test("opening a tracked doc captures an `opened` baseline equal to the buffer (INV-18)", async () => {
|
||||
const doc = await openDoc();
|
||||
await getApi();
|
||||
test("entering coediting on a snapshot-mode doc captures an `entered` baseline equal to the buffer (INV-7)", async () => {
|
||||
const doc = await openAndCoedit();
|
||||
const api = await getApi();
|
||||
assert.strictEqual(api.diffViewController.modeOf(docUri()), "snapshot", "no git repo → snapshot mode");
|
||||
const baseline = api.diffViewController.getBaseline(docUri());
|
||||
assert.ok(baseline, "baseline captured on first sight");
|
||||
assert.strictEqual(baseline!.reason, "opened");
|
||||
assert.strictEqual(baseline!.text, doc.getText(), "baseline = open-time buffer");
|
||||
assert.ok(baseline, "baseline captured on coediting entry");
|
||||
assert.strictEqual(baseline!.reason, "entered");
|
||||
assert.strictEqual(baseline!.text, doc.getText(), "baseline = entry-time buffer");
|
||||
});
|
||||
|
||||
test("typing leaves the baseline unchanged while the buffer diverges", async () => {
|
||||
const doc = await openDoc();
|
||||
const doc = await openAndCoedit();
|
||||
const api = await getApi();
|
||||
const before = api.diffViewController.getBaseline(docUri())!.text;
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
@@ -55,9 +72,10 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
|
||||
assert.notStrictEqual(doc.getText(), before, "buffer diverged");
|
||||
});
|
||||
|
||||
test("accepting a proposal advances the baseline past the landed text (PUC-2, INV-18)", async () => {
|
||||
const doc = await openDoc();
|
||||
test("accepting a proposal does NOT advance the baseline — the landed text stays a change (INV-7/D21, #48 retired)", async () => {
|
||||
const doc = await openAndCoedit();
|
||||
const api = await getApi();
|
||||
const before = api.diffViewController.getBaseline(docUri())!;
|
||||
const start = doc.getText().indexOf(TARGET);
|
||||
assert.ok(start >= 0, "fixture contains the target");
|
||||
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||
@@ -73,14 +91,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
|
||||
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
|
||||
await settle();
|
||||
const baseline = api.diffViewController.getBaseline(docUri())!;
|
||||
assert.strictEqual(baseline.reason, "machine-landing", "baseline advanced on the landing");
|
||||
assert.ok(baseline.text.includes(REPLACEMENT), "landed text is in the baseline (won't show as a change)");
|
||||
assert.ok(!baseline.text.includes(TARGET), "old target gone from the baseline too");
|
||||
assert.strictEqual(baseline.text, doc.getText(), "baseline == buffer right after the landing");
|
||||
assert.strictEqual(baseline.reason, before.reason, "baseline reason untouched by the landing");
|
||||
assert.strictEqual(baseline.text, before.text, "baseline text untouched by the landing");
|
||||
assert.ok(!baseline.text.includes(REPLACEMENT), "landed text is NOT folded into the baseline");
|
||||
assert.notStrictEqual(baseline.text, doc.getText(), "baseline != buffer — the landing reads as a change");
|
||||
});
|
||||
|
||||
test("an operator edit after the landing makes baseline ≠ buffer (the operator delta)", async () => {
|
||||
const doc = await openDoc();
|
||||
test("an operator edit after the landing keeps baseline ≠ buffer (the operator delta)", async () => {
|
||||
const doc = await openAndCoedit();
|
||||
const api = await getApi();
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, new vscode.Position(0, 0), "POST-LANDING OPERATOR LINE\n");
|
||||
@@ -89,14 +107,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
|
||||
assert.notStrictEqual(
|
||||
api.diffViewController.getBaseline(docUri())!.text,
|
||||
doc.getText(),
|
||||
"operator changes show against the advanced baseline",
|
||||
"operator changes show against the untouched baseline",
|
||||
);
|
||||
});
|
||||
|
||||
test("pin resets the baseline to now: baseline == buffer, reason pinned", async () => {
|
||||
const doc = await openDoc();
|
||||
test("markReviewed resets the baseline to now: baseline == buffer, reason pinned", async () => {
|
||||
const doc = await openAndCoedit();
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
|
||||
await vscode.commands.executeCommand("cowriting.markReviewed");
|
||||
await settle();
|
||||
const baseline = api.diffViewController.getBaseline(docUri())!;
|
||||
assert.strictEqual(baseline.reason, "pinned");
|
||||
@@ -104,14 +122,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
|
||||
});
|
||||
|
||||
test("the baseline is persisted in GLOBAL storage, never the repo (INV-19)", async () => {
|
||||
await openDoc();
|
||||
await openAndCoedit();
|
||||
const api = await getApi();
|
||||
const p = api.diffViewController.baselineFilePath(docUri());
|
||||
assert.ok(p, "storage-backed baseline path is available for a file: doc");
|
||||
assert.ok(fs.existsSync(p!), `baseline file exists at ${p}`);
|
||||
const onDisk = JSON.parse(fs.readFileSync(p!, "utf8"));
|
||||
assert.strictEqual(onDisk.uri, docUri(), "baseline records the document URI");
|
||||
assert.strictEqual(onDisk.reason, "pinned", "last epoch (pin) persisted");
|
||||
assert.strictEqual(onDisk.reason, "pinned", "last epoch (markReviewed) persisted");
|
||||
assert.strictEqual(onDisk.text, api.diffViewController.getBaseline(docUri())!.text, "on-disk == in-memory");
|
||||
// INV-19: baseline lives under the extension's storage dir, not the repo.
|
||||
assert.ok(!p!.includes(`${path.sep}.threads${path.sep}`), "baseline is NOT in the sidecar tree");
|
||||
@@ -127,11 +145,12 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
|
||||
const outsideUri = vscode.Uri.file(outsidePath);
|
||||
const doc = await vscode.workspace.openTextDocument(outsideUri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
// Captured on open even though it is NOT under the workspace folder.
|
||||
// Captured on coediting entry even though it is NOT under the workspace folder.
|
||||
const baseline = api.diffViewController.getBaseline(outsideUri.toString());
|
||||
assert.ok(baseline, "baseline captured for an out-of-folder file");
|
||||
assert.strictEqual(baseline!.reason, "opened");
|
||||
assert.strictEqual(baseline!.reason, "entered");
|
||||
const fp = api.diffViewController.baselineFilePath(outsideUri.toString());
|
||||
assert.ok(fp && fs.existsSync(fp), "outside-file baseline persisted in global storage");
|
||||
assert.ok(!fp!.startsWith(WS + path.sep), "not under the workspace folder");
|
||||
@@ -143,6 +162,7 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
|
||||
const api = await getApi();
|
||||
const untitled = await vscode.workspace.openTextDocument({ content: "scratch line\n", language: "markdown" });
|
||||
await vscode.window.showTextDocument(untitled);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
const key = untitled.uri.toString();
|
||||
assert.strictEqual(untitled.uri.scheme, "untitled", "it really is an untitled buffer");
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
import * as assert from "assert";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { renderPlain } from "../../../src/trackChangesModel";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal");
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
async function propose(
|
||||
doc: vscode.TextDocument,
|
||||
key: string,
|
||||
target: string,
|
||||
newText: string,
|
||||
turnId: string,
|
||||
): Promise<string> {
|
||||
const start = doc.getText().indexOf(target);
|
||||
assert.ok(start >= 0, `fixture contains "${target}"`);
|
||||
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||
uri: key,
|
||||
start,
|
||||
end: start + target.length,
|
||||
newText,
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f10rev",
|
||||
turnId,
|
||||
});
|
||||
assert.ok(id, "propose returns an id");
|
||||
return id!;
|
||||
}
|
||||
|
||||
// F10 host E2E (no LLM): the rendered preview is the single INTERACTIVE review
|
||||
// surface. This suite owns docs/f10review.md (its main flow is order-dependent)
|
||||
// plus its own disjoint fresh docs for the isolated cases (status-bar, toggle).
|
||||
// The editor is zero-decoration; everything observable here is the data layer +
|
||||
// the on-state renderReview HTML the panel posts.
|
||||
suite("F10 interactive review (host E2E — preview is the single review surface, no LLM)", () => {
|
||||
const DOC_REL = "docs/f10review.md";
|
||||
const PROSE = "The original review paragraph that lives in this doc.";
|
||||
const T1 = "A first claude target sentence here.";
|
||||
const T2 = "A second claude target sentence here.";
|
||||
|
||||
test("open on a markdown doc → panel open, fresh baseline all-unchanged, mode is on (PUC-1)", async () => {
|
||||
const { doc, key } = await freshDoc(DOC_REL, `# F10 review\n\n${PROSE}\n\n${T1}\n\n${T2}\n`);
|
||||
const api = await getApi();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel yet");
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
|
||||
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default on (INV-33)");
|
||||
const model = api.trackChangesPreviewController.getLastModel(key);
|
||||
assert.ok(model && model.length > 0, "a model was computed");
|
||||
assert.ok(model!.every((o) => o.kind === "unchanged"), "fresh baseline == buffer → every block unchanged");
|
||||
void doc;
|
||||
});
|
||||
|
||||
test("typing produces an added/changed block and a cw-by-human span in the on-state render (PUC-2)", async () => {
|
||||
const { key } = await reopen(DOC_REL);
|
||||
const api = await getApi();
|
||||
const doc = byKey(key)!;
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||
await settle();
|
||||
const kinds = (api.trackChangesPreviewController.getLastModel(key) ?? []).map((o) => o.kind);
|
||||
assert.ok(kinds.some((k) => k === "added" || k === "changed"), "an added/changed block after typing");
|
||||
// Attribution recorded the human span (data layer), and the on-state render
|
||||
// author-colors that prose as cw-by-human.
|
||||
assert.ok(
|
||||
api.attributionController.spansFor(doc).some((s) => s.author === "human"),
|
||||
"a human span was recorded for the typed text",
|
||||
);
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
assert.match(html, /<span class="cw-by-human">/, "typed text is author-colored human in the on-state");
|
||||
});
|
||||
|
||||
test("propose → the preview surfaces it as a cw-proposal block with ✓/✗ actions (PUC-3)", async () => {
|
||||
const { doc, key } = await reopen(DOC_REL);
|
||||
const api = await getApi();
|
||||
const id = await propose(doc, key, T1, "A FIRST claude REPLACEMENT sentence.", "turn-f10-1");
|
||||
await settle();
|
||||
const views = api.proposalController.listProposals(doc);
|
||||
assert.ok(views.some((v) => v.id === id), "listProposals returns a view with the id");
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
assert.ok(html.includes(`data-proposal-id="${id}"`), "the preview renders the proposal block by id");
|
||||
assert.match(html, /class="cw-actions"/, "the proposal block carries ✓/✗ actions");
|
||||
// #31: the proposal renders INLINE at its anchor (right after T1's block), not
|
||||
// trailing the whole document — so it appears BEFORE the following T2 block.
|
||||
const pIdx = html.indexOf(`data-proposal-id="${id}"`);
|
||||
const t2Idx = html.indexOf("second claude target");
|
||||
assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)");
|
||||
// INV-10: proposing never touches the document.
|
||||
assert.ok(doc.getText().includes(T1), "document unchanged by propose");
|
||||
});
|
||||
|
||||
test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => {
|
||||
const { doc, key } = await reopen(DOC_REL);
|
||||
const api = await getApi();
|
||||
const id = api.proposalController.listProposals(doc).find((v) => v.replaced === T1)!.id;
|
||||
assert.ok(await api.proposalController.acceptById(DOC_REL, id), "accept applies via the seam");
|
||||
await settle();
|
||||
const replacement = "A FIRST claude REPLACEMENT sentence.";
|
||||
assert.ok(doc.getText().includes(replacement), "replacement landed in the document");
|
||||
assert.ok(!doc.getText().includes(T1), "original target gone");
|
||||
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id), "proposal cleared from listProposals");
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
assert.ok(!html.includes(`data-proposal-id="${id}"`), "the accepted proposal block is gone from the preview");
|
||||
// The baseline advanced on the landing (INV-18): the landed text is not marked.
|
||||
const model = api.trackChangesPreviewController.getLastModel(key) ?? [];
|
||||
const marked = model.some((o) => o.kind !== "unchanged" && o.block.raw.includes("FIRST claude REPLACEMENT"));
|
||||
assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)");
|
||||
});
|
||||
|
||||
test("reject → the proposal vanishes and the document is untouched (PUC-5)", async () => {
|
||||
const { doc, key } = await reopen(DOC_REL);
|
||||
const api = await getApi();
|
||||
const before = doc.getText();
|
||||
const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2");
|
||||
await settle();
|
||||
assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending");
|
||||
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id2), true, "reject");
|
||||
await settle();
|
||||
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
|
||||
assert.strictEqual(doc.getText(), before, "document untouched by reject");
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview");
|
||||
});
|
||||
|
||||
test("toggle annotations off → mode round-trips and the off-state render is plain (no cw- marks) (INV-33)", async () => {
|
||||
// A fresh doc with a pending proposal so the on-state DOES carry a cw- mark,
|
||||
// making the off-state's absence of marks meaningful (not tautological).
|
||||
const { doc, key } = await freshDoc(
|
||||
"docs/f10toggle.md",
|
||||
"# F10 toggle\n\nA toggle target sentence to propose over.\n",
|
||||
);
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const id = await propose(doc, key, "A toggle target sentence to propose over.", "A TOGGLED replacement.", "turn-tog");
|
||||
await settle();
|
||||
|
||||
// on-state: the proposal block + its actions are present.
|
||||
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "starts on");
|
||||
const onHtml = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
assert.ok(onHtml.includes(`data-proposal-id="${id}"`), "on-state shows the proposal block");
|
||||
|
||||
// toggle off → mode round-trips; the off-state body is plain markdown.
|
||||
api.trackChangesPreviewController.setMode(key, "off");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "off", "mode flipped to off");
|
||||
assert.ok(api.trackChangesPreviewController.isOpen(key), "panel stays open across the toggle");
|
||||
// renderHtmlFor is the on-state seam; the off-state body is renderPlain(current)
|
||||
// (INV-33). Assert the actual off-state body the controller posts has no cw-
|
||||
// author/proposal marks — meaningful because the on-state above DID carry one.
|
||||
const offBody = renderPlain(doc.getText());
|
||||
assert.ok(!/cw-proposal|cw-by-claude|cw-by-human|cw-del/.test(offBody), "off-state render carries no cw- marks");
|
||||
|
||||
// toggle back on → marks return.
|
||||
api.trackChangesPreviewController.setMode(key, "on");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "mode flipped back on");
|
||||
assert.ok(
|
||||
api.trackChangesPreviewController.renderHtmlFor(key).includes(`data-proposal-id="${id}"`),
|
||||
"on-state shows the proposal block again",
|
||||
);
|
||||
});
|
||||
|
||||
test("status-bar (PUC-6): a pending proposal with NO panel shows the indicator; opening the preview hides it", async () => {
|
||||
// Isolated fresh doc: no preview opened, so the off-panel indicator is live.
|
||||
const { doc, key } = await freshDoc("docs/f10status.md", "# F10 status\n\nA status target sentence here.\n");
|
||||
const api = await getApi();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for this doc");
|
||||
await propose(doc, key, "A status target sentence here.", "A STATUS replacement.", "turn-stat");
|
||||
await settle();
|
||||
const text = api.trackChangesPreviewController.statusText();
|
||||
assert.ok(text && text.length > 0, "the off-panel indicator shows a non-empty status");
|
||||
assert.match(text!, /1 Claude proposal/, "it mentions the pending count");
|
||||
|
||||
// open the preview for this doc → the off-panel indicator hides (undefined).
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
|
||||
assert.strictEqual(api.trackChangesPreviewController.statusText(), undefined, "indicator hidden once the panel is open");
|
||||
});
|
||||
|
||||
test("clean editor: data layers intact AND the retired in-editor surfaces are gone (INV-32)", async () => {
|
||||
const { doc, key } = await freshDoc("docs/f10clean.md", "# F10 clean\n\nA clean target sentence here.\n");
|
||||
const api = await getApi();
|
||||
const id = await propose(doc, key, "A clean target sentence here.", "A CLAUDE clean replacement.", "turn-clean");
|
||||
await settle();
|
||||
assert.ok(await api.proposalController.acceptById("docs/f10clean.md", id), "accept lands the Claude edit");
|
||||
await settle();
|
||||
// data layer intact: a Claude (agent) attribution span exists.
|
||||
assert.ok(
|
||||
api.attributionController.getSpans("docs/f10clean.md").some((s) => s.authorKind === "agent"),
|
||||
"agent span recorded (attribution data layer intact)",
|
||||
);
|
||||
// the retired in-editor surfaces are gone from the palette (no editor decorations — INV-32).
|
||||
const all = await vscode.commands.getCommands(true);
|
||||
assert.ok(!all.includes("cowriting.toggleAttribution"), "cowriting.toggleAttribution is retired");
|
||||
// #34: the F6 two-pane diff VIEW was removed (F10 preview is the single review
|
||||
// surface) — its toggle command + ctrl+alt+d keybinding are gone entirely.
|
||||
assert.ok(!all.includes("cowriting.toggleDiffView"), "cowriting.toggleDiffView command is gone (#34)");
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||
const dKb = (pkg.contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find(
|
||||
(k) => k.command === "cowriting.toggleDiffView",
|
||||
);
|
||||
assert.ok(!dKb, "the toggleDiffView keybinding is gone from package.json (#34)");
|
||||
// …but the F6 baseline data layer survives: pinDiffBaseline stays a real command.
|
||||
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline (baseline data layer) is kept");
|
||||
void key;
|
||||
});
|
||||
});
|
||||
|
||||
// ---- helpers reused across the order-dependent main-flow tests ----
|
||||
|
||||
function byKey(key: string): vscode.TextDocument | undefined {
|
||||
return vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
|
||||
}
|
||||
async function reopen(rel: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const uri = vscode.Uri.file(path.join(WS, rel));
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
@@ -10,11 +10,13 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.diffViewController, "exports preview + diffView");
|
||||
assert.ok(api?.editFlow && api?.diffViewController, "exports editFlow + diffView");
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */
|
||||
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key.
|
||||
* Also enters coediting (INV-10/Task 2): the baseline is established only on
|
||||
* entry, not merely on open. */
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
@@ -22,47 +24,28 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
// F11 host E2E (no LLM): the preview toolbar is the primary interaction surface.
|
||||
// The webview posts intent messages; the host routes them through the existing
|
||||
// F4/F6/F3 seams (INV-35). The webview DOM (real button clicks) is sealed and
|
||||
// manual-smoke only; here we simulate the inbound messages via `receiveMessage`.
|
||||
suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () => {
|
||||
// SLICE-1: the Pin baseline button.
|
||||
test("pinBaseline message pins the PREVIEWED doc → marks clear, baseline reason is pinned (PUC-5, INV-35)", async () => {
|
||||
const { doc, key } = await freshDoc("docs/f11pin.md", "# F11 pin\n\nA baseline paragraph that will diverge.\n");
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
|
||||
|
||||
// Diverge from the opened baseline so the preview carries a real change-mark.
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed paragraph that diverges.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||
await settle();
|
||||
const marked = (api.trackChangesPreviewController.getLastModel(key) ?? []).some((o) => o.kind !== "unchanged");
|
||||
assert.ok(marked, "the typed paragraph shows as a change before pinning");
|
||||
|
||||
// Simulate the webview's Pin baseline button posting its intent.
|
||||
api.trackChangesPreviewController.receiveMessage(key, { type: "pinBaseline" });
|
||||
await settle();
|
||||
|
||||
const model = api.trackChangesPreviewController.getLastModel(key) ?? [];
|
||||
assert.ok(model.length > 0 && model.every((o) => o.kind === "unchanged"), "after pin, every block is unchanged");
|
||||
assert.strictEqual(api.diffViewController.getBaseline(key)?.reason, "pinned", "baseline reason advanced to pinned");
|
||||
});
|
||||
|
||||
// F11/F12 host E2E (no LLM): EditFlow.runEditAndPropose is the turn→proposal(s)
|
||||
// cut shared by every Ask-Claude entry point (comments-first ask, editDocument,
|
||||
// editSelection). Originally exercised via the now-sunset review webview's
|
||||
// toolbar (Task 8 deleted that webview and its message-routing seam,
|
||||
// `receiveMessage`/`pinBaseline` webview-message coverage duplicates
|
||||
// diffView.test.ts / baselineRouter.test.ts's `markReviewed` tests) — these
|
||||
// tests drive `runEditAndPropose` directly, the same programmatic seam the
|
||||
// deleted toolbar itself called into.
|
||||
suite("F11/F12 — EditFlow document/selection edit turns (host E2E, no LLM)", () => {
|
||||
// SLICE-1 reachability: the orphaned pin command gets a real palette `when`.
|
||||
test("pinDiffBaseline is reachable from the command palette (when: editorLangId == markdown)", async () => {
|
||||
// Renamed cowriting.pinDiffBaseline → cowriting.markReviewed (Task 2, D14).
|
||||
test("markReviewed is reachable from the command palette (when: editorLangId == markdown)", async () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
|
||||
(m) => m.command === "cowriting.pinDiffBaseline",
|
||||
(m) => m.command === "cowriting.markReviewed",
|
||||
);
|
||||
assert.ok(entry, "pinDiffBaseline has a commandPalette entry");
|
||||
assert.ok(entry, "markReviewed has a commandPalette entry");
|
||||
assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)");
|
||||
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
||||
});
|
||||
@@ -76,18 +59,16 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
"# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n",
|
||||
);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
// Stub the host edit turn (no LLM in CI): rewrite two distinct words.
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# F11 doc\n\nThe quick RED fox jumps over the lazy CAT.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f11-doc",
|
||||
}));
|
||||
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "two changed words in one block → ONE block proposal (INV-39)");
|
||||
|
||||
@@ -99,8 +80,10 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
"The quick RED fox jumps over the lazy CAT.",
|
||||
"the block proposal carries the whole rewritten paragraph",
|
||||
);
|
||||
// INV-10: proposing never mutates the document.
|
||||
assert.ok(doc.getText().includes("brown fox") && doc.getText().includes("lazy dog"), "document unchanged by propose");
|
||||
// F12 (INV-48): EditorProposalController optimistically applies the proposed
|
||||
// text, so after settle the buffer has the replacement. The proposal is still
|
||||
// pending (not finalized) until Accept.
|
||||
assert.ok(doc.getText().includes("RED fox") && doc.getText().includes("lazy CAT"), "F12 optimistic-apply: proposed text in buffer");
|
||||
void key;
|
||||
});
|
||||
|
||||
@@ -109,19 +92,17 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
const body = "# F11 sel\n\nThe target paragraph Claude will rewrite.\n\nAnother untouched paragraph.\n";
|
||||
const { doc, key } = await freshDoc("docs/f11sel.md", body);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
const target = "The target paragraph Claude will rewrite.";
|
||||
const start = doc.getText().indexOf(target);
|
||||
const end = start + target.length;
|
||||
ctl.setEditTurnForTest(async (_instruction, text) => {
|
||||
editFlow.setEditTurnForTest(async (_instruction, text) => {
|
||||
assert.strictEqual(text, target, "the turn receives exactly the selected source range");
|
||||
return { replacement: "The REWRITTEN paragraph from Claude.", model: "sonnet", sessionId: "e2e-f11-sel" };
|
||||
});
|
||||
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal");
|
||||
const views = api.proposalController.listProposals(doc);
|
||||
@@ -129,7 +110,9 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
assert.ok(view, "the proposal is live");
|
||||
assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement");
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -137,13 +120,11 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
test("runEditAndPropose(range) where Claude returns the selection unchanged → no proposal", async () => {
|
||||
const { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
const target = "Leave me exactly as I am.";
|
||||
const start = doc.getText().indexOf(target);
|
||||
ctl.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change");
|
||||
editFlow.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change");
|
||||
assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing");
|
||||
});
|
||||
|
||||
@@ -155,12 +136,10 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
const rewrite = "# F11 accept\n\nThe brown fox QUIETLY sleeps today.\n";
|
||||
const { doc } = await freshDoc("docs/f11accept.md", original);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "expand the sentence");
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "expand the sentence");
|
||||
await settle();
|
||||
assert.ok(ids.length >= 1, "the rewrite produced at least one proposal");
|
||||
|
||||
@@ -174,63 +153,21 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
|
||||
});
|
||||
|
||||
// SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown.
|
||||
test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => {
|
||||
// SLICE-3: the document-scoped command stays registered for #42 reuse (now behind
|
||||
// the unified `cowriting.edit`). editDocument is hidden from the palette; the
|
||||
// markdown-guarded user-facing palette command is `cowriting.edit`.
|
||||
test("cowriting.editDocument stays registered; cowriting.edit is the markdown-guarded palette command", async () => {
|
||||
const all = await vscode.commands.getCommands(true);
|
||||
assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered");
|
||||
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.editDocument",
|
||||
);
|
||||
assert.ok(entry, "editDocument has a commandPalette entry");
|
||||
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
||||
});
|
||||
|
||||
// SLICE-5: the minimal right-click gateway lives in editor/title (markdown only).
|
||||
test("the editor/title gateway opens the preview, and the menu entry is markdown-guarded (PUC-6)", async () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||
const entry = (pkg.contributes.menus["editor/title"] as Array<{ command: string; when?: string }>).find(
|
||||
(m) => m.command === "cowriting.showTrackChangesPreview",
|
||||
);
|
||||
assert.ok(entry, "showTrackChangesPreview is in editor/title");
|
||||
assert.match(entry!.when ?? "", /editorLangId == markdown/, "gateway guarded on markdown");
|
||||
// the command it invokes opens the panel.
|
||||
const { key } = await freshDoc("docs/f11gw.md", "# F11 gateway\n\nReachable end to end.\n");
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "gateway command opens the preview");
|
||||
});
|
||||
|
||||
// SLICE-5: edit controls are inert on a non-authorable (read-only scheme) doc.
|
||||
test("toolbar edit controls are disabled for a non-authorable document (PUC-1/7)", async () => {
|
||||
const SCHEME = "cwf11ro";
|
||||
const provider = new (class implements vscode.TextDocumentContentProvider {
|
||||
onDidChange = undefined;
|
||||
provideTextDocumentContent(): string {
|
||||
return "# Read only\n\nThis markdown doc is not authorable.\n";
|
||||
}
|
||||
})();
|
||||
const reg = vscode.workspace.registerTextDocumentContentProvider(SCHEME, provider);
|
||||
try {
|
||||
const uri = vscode.Uri.parse(`${SCHEME}:/readonly.md`);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.languages.setTextDocumentLanguage(doc, "markdown");
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
const key = uri.toString();
|
||||
assert.strictEqual(doc.languageId, "markdown", "fixture is markdown");
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "preview opens (reading is always allowed)");
|
||||
assert.strictEqual(
|
||||
api.trackChangesPreviewController.editControlsEnabled(key),
|
||||
false,
|
||||
"Pin + Ask-Claude controls are disabled on a non-authorable doc",
|
||||
);
|
||||
} finally {
|
||||
reg.dispose();
|
||||
}
|
||||
assert.ok(all.includes("cowriting.edit"), "unified edit command registered");
|
||||
const palette = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")).contributes
|
||||
.menus.commandPalette as Array<{ command: string; when?: string }>;
|
||||
// editDocument is hidden (routed via cowriting.edit).
|
||||
const docEntry = palette.find((m) => m.command === "cowriting.editDocument");
|
||||
assert.strictEqual(docEntry?.when, "false", "editDocument hidden from the palette");
|
||||
// cowriting.edit is the user-facing, markdown-guarded entry.
|
||||
const editEntry = palette.find((m) => m.command === "cowriting.edit");
|
||||
assert.ok(editEntry, "cowriting.edit has a commandPalette entry");
|
||||
assert.match(editEntry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers");
|
||||
assert.ok(api?.editFlow && api?.proposalController, "exports controllers");
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: proposal rendering (F12 optimistic-apply, driven by
|
||||
// onDidChangeProposals) is gated on coediting (Task 4).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
@@ -33,19 +36,18 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
|
||||
test("acceptAll applies every pending proposal and reconstructs the document", async () => {
|
||||
const original = "# All\n\nFirst para alpha.\n\nSecond para beta.\n\nThird para gamma.\n";
|
||||
const rewrite = "# All\n\nFirst para ALPHA.\n\nSecond para BETA.\n\nThird para GAMMA.\n";
|
||||
const { doc, key } = await freshDoc("docs/f12-all.md", original);
|
||||
const { doc } = await freshDoc("docs/f12-all.md", original);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns");
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 3, "three changed blocks → three pending proposals");
|
||||
|
||||
// Simulate the toolbar "Accept all" button posting its intent.
|
||||
ctl.receiveMessage(key, { type: "acceptAll" });
|
||||
// Route through the real "Keep all" command (Task 8, PUC-2) — the doc is the
|
||||
// active editor from freshDoc, matching the top-of-file CodeLens's own target.
|
||||
await vscode.commands.executeCommand("cowriting.acceptAllProposals");
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
@@ -60,12 +62,10 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
|
||||
const rewrite = "# Mix\n\nKeep ALPHA here.\n\nKeep GAMMA here.\n";
|
||||
const { doc } = await freshDoc("docs/f12-orphan.md", original);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase");
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 2, "two pending proposals");
|
||||
|
||||
@@ -92,20 +92,19 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 1, "the orphaned proposal remains pending");
|
||||
});
|
||||
|
||||
// A single pending proposal still applies through the batch path (the button is
|
||||
// hidden < 2 pending in the webview, but the command/seam handle any count).
|
||||
// A single pending proposal still applies through the batch path (the
|
||||
// top-of-file "Keep all" CodeLens is hidden < 2 pending, Task 8 PUC-2, but the
|
||||
// command/seam handle any count).
|
||||
test("acceptAllProposals with one pending proposal applies it", async () => {
|
||||
const { doc } = await freshDoc("docs/f12-one.md", "# One\n\nThe only paragraph here.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# One\n\nThe ONLY paragraph here.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f12-one",
|
||||
}));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase only");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase only");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "one pending proposal");
|
||||
const { applied, skipped } = await api.proposalController.acceptAllProposals(doc);
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
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?.diffViewController && api?.proposalController, "exports controllers");
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Also enters coediting (INV-10/Task 2): the baseline is established only on
|
||||
* entry, not merely on open. */
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
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();
|
||||
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 editFlow = api.editFlow;
|
||||
const p = api.proposalController;
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||
await settle();
|
||||
for (const id of ids) await p.optimisticApply(doc, id);
|
||||
await settle();
|
||||
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 editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" }));
|
||||
await editFlow.runEditAndPropose(doc, { kind: "document" }, "x");
|
||||
await settle(); await settle();
|
||||
assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here.");
|
||||
});
|
||||
});
|
||||
|
||||
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 editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" }));
|
||||
await editFlow.runEditAndPropose(doc, { kind: "document" }, "recolor the fox");
|
||||
await settle(); await settle();
|
||||
assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer");
|
||||
const v = api.proposalController.listProposals(doc)[0];
|
||||
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 editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||
await settle(); await settle();
|
||||
// human tweaks the inserted text
|
||||
const at = doc.getText().indexOf("ALPHA");
|
||||
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-one via revertInPlace reverts in place; rejectAll clears every proposal", async () => {
|
||||
const { doc } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n");
|
||||
const api = await getApi();
|
||||
const p = api.proposalController;
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||
await settle(); await settle();
|
||||
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"));
|
||||
// reject ONE via the same seam the per-proposal "✗ Reject" CodeLens uses
|
||||
// (Task 8, PUC-2) → that block reverts, the other stays applied.
|
||||
const docKey = p.keyFor(doc);
|
||||
assert.ok(await p.revertInPlace(docKey, ids[0]), "revert one proposal");
|
||||
await settle(); await settle();
|
||||
assert.ok(doc.getText().includes("uno aaa.") && doc.getText().includes("BBB"), "one reverted, one applied");
|
||||
// rejectAll via the same seam the top-of-file "✗ Reject all" CodeLens uses →
|
||||
// all gone, document restored.
|
||||
const { reverted } = await p.rejectAll(doc);
|
||||
assert.strictEqual(reverted, 1, "the one remaining proposal reverted");
|
||||
await settle(); await settle();
|
||||
assert.strictEqual(doc.getText(), "# P\n\nuno aaa.\n\ndos bbb.\n", "document restored");
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
// #70 (INV-5): rejecting a proposal AFTER typing inside its pending range must
|
||||
// still restore the retained original. The exact-substring anchor is orphaned by
|
||||
// the interior tweak (INV-1/INV-11) — the revert falls back to the F12
|
||||
// applied-range bookkeeping (appliedSpans), which shift-tracks the span through
|
||||
// interior edits.
|
||||
suite("F12 inline diff — #70 reject after interior edit (INV-5)", () => {
|
||||
test("revertInPlace restores the original after a tweak inside the pending range", async () => {
|
||||
const ORIGINAL = "The quick brown fox jumps.";
|
||||
const { doc } = await freshDoc("docs/s70-reject-tweaked.md", `# T\n\n${ORIGINAL}\n`);
|
||||
const api = await getApi();
|
||||
const p = api.proposalController;
|
||||
const docKey = p.keyFor(doc);
|
||||
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
|
||||
const id = await p.propose(doc, fp, "The rapid brown fox jumps.",
|
||||
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
||||
await p.optimisticApply(doc, id!);
|
||||
await settle();
|
||||
assert.ok(doc.getText().includes("The rapid brown fox jumps."), "optimistically applied");
|
||||
// Human types INSIDE the pending range → orphans the exact anchor (INV-11).
|
||||
const at = doc.getText().indexOf("rapid");
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + "rapid".length)), "RAPID-ish");
|
||||
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
|
||||
await settle();
|
||||
assert.strictEqual(p.listProposals(doc).find((v) => v.id === id)!.anchorStart, null, "anchor orphaned by the tweak");
|
||||
// ✗ Reject: must restore the retained original EXACTLY (INV-5), not skip.
|
||||
assert.ok(await p.revertInPlace(docKey, id!), "reject reports success");
|
||||
await settle();
|
||||
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
|
||||
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
|
||||
});
|
||||
|
||||
test("rejectByIdInPlace routes the tweaked-applied case the same way", async () => {
|
||||
const ORIGINAL = "A steady closing line.";
|
||||
const { doc } = await freshDoc("docs/s70-reject-route.md", `# T\n\n${ORIGINAL}\n`);
|
||||
const api = await getApi();
|
||||
const p = api.proposalController;
|
||||
const docKey = p.keyFor(doc);
|
||||
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
|
||||
const id = await p.propose(doc, fp, "A wobbly closing line.",
|
||||
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
||||
await p.optimisticApply(doc, id!);
|
||||
await settle();
|
||||
const at = doc.getText().indexOf("wobbly");
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at)), "very-");
|
||||
assert.ok(await vscode.workspace.applyEdit(we), "interior insertion applied");
|
||||
await settle();
|
||||
assert.ok(await p.rejectByIdInPlace(docKey, id!), "gesture-level reject succeeds");
|
||||
await settle();
|
||||
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
|
||||
});
|
||||
|
||||
// Fix direction (b): when the tracked span itself is distrusted (an edit
|
||||
// straddled its boundary — here a deletion running from the heading into the
|
||||
// span's interior), the reject FAILS honestly: warning path, proposal kept,
|
||||
// buffer untouched. Silent-skip-and-report-success (the #70 bug) must not come
|
||||
// back; reverting at a clamped guessed offset (INV-11) must not either.
|
||||
// (A boundary-straddling DELETION is used because VS Code minimizes workspace
|
||||
// edits before change events fire — a wholesale rewrite sharing a prefix/suffix
|
||||
// decomposes into interior hunks the span legitimately survives.)
|
||||
test("reject fails loudly — proposal kept, buffer untouched — when the span is distrusted", async () => {
|
||||
const ORIGINAL = "Sentence one stands here.";
|
||||
const { doc } = await freshDoc("docs/s70-reject-distrust.md", `# T\n\n${ORIGINAL}\n`);
|
||||
const api = await getApi();
|
||||
const p = api.proposalController;
|
||||
const docKey = p.keyFor(doc);
|
||||
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
|
||||
const id = await p.propose(doc, fp, "Sentence ONE stands here.",
|
||||
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
||||
await p.optimisticApply(doc, id!);
|
||||
await settle();
|
||||
// Delete from inside the heading through the middle of the applied span:
|
||||
// the edit straddles the span's start boundary → the tracked span is
|
||||
// distrusted AND the exact anchor no longer resolves.
|
||||
const mid = doc.getText().indexOf("ONE");
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
we.replace(doc.uri, new vscode.Range(doc.positionAt(2), doc.positionAt(mid + 1)), "");
|
||||
assert.ok(await vscode.workspace.applyEdit(we), "boundary-straddling deletion applied");
|
||||
await settle();
|
||||
const before = doc.getText();
|
||||
assert.strictEqual(await p.revertInPlace(docKey, id!), false, "reject reports failure (no silent success)");
|
||||
await settle();
|
||||
assert.strictEqual(doc.getText(), before, "buffer untouched — never reverted by guess (INV-11)");
|
||||
assert.ok(p.listProposals(doc).some((v) => v.id === id), "proposal kept (not silently dropped)");
|
||||
// Cleanup for later tests: the never-locatable husk is discarded via the
|
||||
// plain record-only reject.
|
||||
assert.strictEqual(p.rejectById(docKey, id!), true);
|
||||
});
|
||||
|
||||
// rejectAll must revert a tweaked (orphaned-anchor) proposal via the same
|
||||
// tracked-span fallback, ordered descending by that span so earlier reverts
|
||||
// never shift later ones — and must count what it could not revert.
|
||||
test("rejectAll reverts tweaked proposals via the tracked span and reports skips", async () => {
|
||||
const { doc } = await freshDoc("docs/s70-rejall-tweak.md", "# T\n\nOne aaa.\n\nTwo bbb.\n");
|
||||
const api = await getApi();
|
||||
const p = api.proposalController;
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||
await settle();
|
||||
for (const id of ids) await p.optimisticApply(doc, id);
|
||||
await settle();
|
||||
// Tweak INSIDE the first applied block → its exact anchor orphans.
|
||||
const at = doc.getText().indexOf("AAA");
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 3)), "AAAZ");
|
||||
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
|
||||
await settle();
|
||||
const { reverted, skipped } = await p.rejectAll(doc);
|
||||
await settle();
|
||||
assert.strictEqual(reverted, ids.length, "ALL proposals reverted, tweaked one included");
|
||||
assert.strictEqual(skipped, 0, "nothing skipped");
|
||||
assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document fully restored (INV-5)");
|
||||
assert.strictEqual(p.listProposals(doc).length, 0, "all cleared");
|
||||
});
|
||||
|
||||
// The decision gestures must stay REACHABLE for a tweaked proposal: the ✓/✗
|
||||
// CodeLens pair anchors at the tracked span when the interior tweak orphans
|
||||
// the exact anchor — otherwise the fixed reject path exists only at the API.
|
||||
test("the ✓ Keep / ✗ Reject CodeLens pair survives an interior tweak (tracked-span anchor)", async () => {
|
||||
const ORIGINAL = "Lens target sentence here.";
|
||||
const { doc } = await freshDoc("docs/s70-lens-tweak.md", `# T\n\n${ORIGINAL}\n`);
|
||||
const api = await getApi();
|
||||
const p = api.proposalController;
|
||||
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
|
||||
const id = await p.propose(doc, fp, "Lens TARGET sentence here.",
|
||||
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
||||
await p.optimisticApply(doc, id!);
|
||||
await settle();
|
||||
const at = doc.getText().indexOf("TARGET");
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 6)), "TARGET-tweaked");
|
||||
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
|
||||
await settle();
|
||||
assert.strictEqual(p.listProposals(doc).find((v) => v.id === id)!.anchorStart, null, "anchor orphaned");
|
||||
const lenses = api.editorProposalController.provideCodeLenses(doc);
|
||||
const titles = lenses.map((l) => l.command?.title);
|
||||
assert.deepStrictEqual(titles, ["✓ Keep", "✗ Reject"], "the pair survives the tweak");
|
||||
assert.deepStrictEqual(lenses[1].command!.arguments, [id], "reject lens still targets the tweaked proposal");
|
||||
// The reject it routes at still restores the original (the Task-1 contract).
|
||||
assert.ok(await p.revertInPlace(p.keyFor(doc), id!), "reject via the surviving gesture succeeds");
|
||||
await settle();
|
||||
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
|
||||
});
|
||||
});
|
||||
|
||||
// Reload-safety: a proposal that was optimistically applied in a PRIOR session
|
||||
// persists with `original` set and its fp re-anchored to the applied text. A fresh
|
||||
// controller (empty in-memory `applied` set) must NOT re-capture `original` from the
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController, "exports preview controller");
|
||||
assert.ok(api?.threadController, "exports thread controller");
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
// INV-10: cowriting.editDocument now warns instead of editing a non-entered
|
||||
// doc (Task 4) — enter it here (briefly making it active) so the tests below
|
||||
// exercise the routing/targeting behavior, not the gate. The caller
|
||||
// re-establishes whichever doc it wants active afterward.
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
@@ -31,50 +37,52 @@ function menu(id: string): Array<{ command: string; when?: string; group?: strin
|
||||
}
|
||||
|
||||
// SLICE-1 / #42 (reach): "Ask Claude to Edit" reachable from the editor BODY and
|
||||
// the editor TAB, selection-aware (selection → editSelection; no selection →
|
||||
// editDocument), both markdown/authorable-gated, both routing through the single
|
||||
// runEditAndPropose path (INV-38). The menu `when` clauses are declarative, so we
|
||||
// assert them directly; the tab-targeting behavior is exercised through the command.
|
||||
// the editor TAB. The two split commands (editSelection / editDocument) were
|
||||
// unified behind ONE user-facing command `cowriting.edit` that auto-routes by
|
||||
// selection at runtime (routeEdit: selection → editSelection, none → editDocument)
|
||||
// — so the menus carry a SINGLE selection-agnostic entry, both markdown/authorable
|
||||
// -gated. The routing itself is unit-tested (routeEdit) and exercised through the
|
||||
// command below; here we assert the declarative menu `when` clauses.
|
||||
suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
// PUC-1/2: editor BODY (editor/context) is selection-aware + markdown-gated.
|
||||
test("editor/context offers editSelection (with selection) and editDocument (without), markdown+authorable", () => {
|
||||
// PUC-1/2: editor BODY (editor/context) offers the single unified entry,
|
||||
// markdown + authorable gated, NOT selection-gated (it routes both cases).
|
||||
test("editor/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown+authorable", () => {
|
||||
const m = menu("editor/context");
|
||||
const sel = m.find((e) => e.command === "cowriting.editSelection");
|
||||
const doc = m.find((e) => e.command === "cowriting.editDocument");
|
||||
assert.ok(sel, "editSelection is in editor/context");
|
||||
assert.ok(doc, "editDocument is in editor/context");
|
||||
|
||||
assert.match(sel!.when ?? "", /editorHasSelection/, "editSelection shows only with a selection");
|
||||
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "editSelection is not gated on NO selection");
|
||||
assert.match(sel!.when ?? "", /editorLangId == markdown/, "editSelection gated on markdown");
|
||||
assert.match(sel!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editSelection gated authorable");
|
||||
|
||||
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "editDocument shows only without a selection");
|
||||
assert.match(doc!.when ?? "", /editorLangId == markdown/, "editDocument gated on markdown");
|
||||
assert.match(doc!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editDocument gated authorable");
|
||||
const edit = m.find((e) => e.command === "cowriting.edit");
|
||||
assert.ok(edit, "cowriting.edit is in editor/context");
|
||||
assert.match(edit!.when ?? "", /editorLangId == markdown/, "gated on markdown");
|
||||
assert.match(edit!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "gated authorable");
|
||||
assert.ok(!/editorHasSelection/.test(edit!.when ?? ""), "not selection-gated — one entry routes both cases");
|
||||
// The old split pair is gone from the menu (the commands stay registered/hidden).
|
||||
assert.ok(!m.some((e) => e.command === "cowriting.editSelection"), "old editSelection menu entry removed");
|
||||
assert.ok(!m.some((e) => e.command === "cowriting.editDocument"), "old editDocument menu entry removed");
|
||||
});
|
||||
|
||||
// PUC-3: editor TAB (editor/title/context) carries the same selection-aware pair.
|
||||
test("editor/title/context offers editSelection (with selection) and editDocument (without), markdown-gated", () => {
|
||||
// PUC-3: editor TAB (editor/title/context) carries the same single entry.
|
||||
test("editor/title/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown-gated", () => {
|
||||
const m = menu("editor/title/context");
|
||||
const sel = m.find((e) => e.command === "cowriting.editSelection");
|
||||
const doc = m.find((e) => e.command === "cowriting.editDocument");
|
||||
assert.ok(sel, "editSelection is in editor/title/context");
|
||||
assert.ok(doc, "editDocument is in editor/title/context");
|
||||
|
||||
assert.match(sel!.when ?? "", /editorHasSelection/, "tab editSelection shows only with a selection");
|
||||
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "tab editSelection is not gated on NO selection");
|
||||
assert.match(sel!.when ?? "", /resourceLangId == markdown/, "tab editSelection gated on markdown");
|
||||
|
||||
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "tab editDocument shows only without a selection");
|
||||
assert.match(doc!.when ?? "", /resourceLangId == markdown/, "tab editDocument gated on markdown");
|
||||
const edit = m.find((e) => e.command === "cowriting.edit");
|
||||
assert.ok(edit, "cowriting.edit is in editor/title/context");
|
||||
assert.match(edit!.when ?? "", /resourceLangId == markdown/, "tab entry gated on markdown");
|
||||
assert.ok(
|
||||
!m.some((e) => e.command === "cowriting.editSelection" || e.command === "cowriting.editDocument"),
|
||||
"old split pair removed from the tab menu",
|
||||
);
|
||||
});
|
||||
|
||||
// PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
|
||||
// not whatever editor happens to be active (mirrors #41's clicked-doc resolution).
|
||||
// Finding 1 fix (native-surfaces code review): editDocument no longer prompts via
|
||||
// the removed askEditInstruction webview stub (that path threw a silent, `void`'d,
|
||||
// unhandled rejection — no toast, no thread, no proposal). It now resolves/focuses
|
||||
// its target document and hands off to ThreadController.askClaude() (D19), same as
|
||||
// editSelection. The turn→proposal cut itself (EditFlow.runEditAndPropose against a
|
||||
// {kind:"document"} target) is exercised directly, without the command, in
|
||||
// f11Toolbar.test.ts / f12Accept.test.ts / f12Review.test.ts — this test's job is
|
||||
// the URI-targeting/focus behavior, plus confirming the routing actually reaches
|
||||
// askClaude (proving Finding 1's dead end is gone).
|
||||
test("editDocument(uri) targets the clicked tab's document, not the active editor", async () => {
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
|
||||
// Doc A is the active editor; Doc B is the "clicked tab" we pass by URI.
|
||||
const a = await freshDoc("docs/f12-active.md", "# Active\n\nThe active editor paragraph.\n");
|
||||
@@ -82,53 +90,68 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
await vscode.window.showTextDocument(a.doc);
|
||||
await settle();
|
||||
|
||||
// Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn.
|
||||
const origInput = vscode.window.showInputBox;
|
||||
(vscode.window as any).showInputBox = async () => "rewrite it";
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f12-tab",
|
||||
}));
|
||||
let askClaudeCalls = 0;
|
||||
// Capture the active-editor doc INSIDE the spy, before delegating to the real
|
||||
// askClaude — askClaude's own `workbench.action.addComment` side effect moves
|
||||
// focus to the ephemeral comment-input widget (a `comment://` URI), which would
|
||||
// make a post-hoc `activeTextEditor` check meaningless. What we're proving here
|
||||
// is editDocument's OWN resolve-and-focus step ran against the right document
|
||||
// before handing off.
|
||||
let focusedDocAtHandoff: string | undefined;
|
||||
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
|
||||
api.threadController.askClaude = async () => {
|
||||
askClaudeCalls++;
|
||||
focusedDocAtHandoff = vscode.window.activeTextEditor?.document.uri.toString();
|
||||
return origAskClaude();
|
||||
};
|
||||
try {
|
||||
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
|
||||
await settle();
|
||||
} finally {
|
||||
(vscode.window as any).showInputBox = origInput;
|
||||
api.threadController.askClaude = origAskClaude;
|
||||
}
|
||||
|
||||
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
|
||||
assert.ok(api.proposalController.listProposals(b.doc).length >= 1, "tab doc B received the document-edit proposal(s)");
|
||||
assert.strictEqual(
|
||||
api.proposalController.listProposals(a.doc).length,
|
||||
0,
|
||||
"active doc A was NOT edited — editDocument honored the tab URI",
|
||||
askClaudeCalls,
|
||||
1,
|
||||
"editDocument(uri) reached ThreadController.askClaude — not the removed prompt stub",
|
||||
);
|
||||
assert.strictEqual(
|
||||
focusedDocAtHandoff,
|
||||
b.doc.uri.toString(),
|
||||
"editDocument(uri) focused the TAB doc B, not the previously-active doc A, before handing off to askClaude",
|
||||
);
|
||||
// INV-10: proposing never mutates the document.
|
||||
assert.ok(b.doc.getText().includes("tab target paragraph"), "tab doc unchanged by propose");
|
||||
});
|
||||
|
||||
// No URI arg (palette / keybinding) → fall back to the active editor.
|
||||
test("editDocument() with no arg targets the active editor", async () => {
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
const a = await freshDoc("docs/f12-noarg.md", "# No arg\n\nThe active doc paragraph here.\n");
|
||||
await vscode.window.showTextDocument(a.doc);
|
||||
await settle();
|
||||
|
||||
const origInput = vscode.window.showInputBox;
|
||||
(vscode.window as any).showInputBox = async () => "rewrite it";
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f12-noarg",
|
||||
}));
|
||||
let askClaudeCalls = 0;
|
||||
// See the sibling test above for why this is captured inside the spy rather
|
||||
// than after askClaude() returns.
|
||||
let focusedDocAtHandoff: string | undefined;
|
||||
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
|
||||
api.threadController.askClaude = async () => {
|
||||
askClaudeCalls++;
|
||||
focusedDocAtHandoff = vscode.window.activeTextEditor?.document.uri.toString();
|
||||
return origAskClaude();
|
||||
};
|
||||
try {
|
||||
await vscode.commands.executeCommand("cowriting.editDocument");
|
||||
await settle();
|
||||
} finally {
|
||||
(vscode.window as any).showInputBox = origInput;
|
||||
api.threadController.askClaude = origAskClaude;
|
||||
}
|
||||
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
|
||||
|
||||
assert.strictEqual(askClaudeCalls, 1, "editDocument() with no arg reached ThreadController.askClaude");
|
||||
assert.strictEqual(
|
||||
focusedDocAtHandoff,
|
||||
a.doc.uri.toString(),
|
||||
"editDocument() with no arg kept the active editor's doc active before handing off to askClaude",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers");
|
||||
assert.ok(api?.proposalController && api?.editorProposalController, "exports controllers");
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument>
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: attribution tracking (accept's per-word attribution, INV-40) is
|
||||
// gated on coediting (Task 4).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return doc;
|
||||
}
|
||||
@@ -36,13 +39,13 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
|
||||
"# Doc\n\nFirst paragraph alpha.\n\nSecond paragraph beta.\n\nThird paragraph gamma.\n",
|
||||
);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# Doc\n\nFirst paragraph ALPHA.\n\nSecond paragraph beta.\n\nThird paragraph GAMMA.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f12-multi",
|
||||
}));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 2, "two changed blocks → two proposals; the unchanged middle block → none");
|
||||
|
||||
@@ -59,13 +62,13 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
|
||||
"# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n",
|
||||
);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f12-fence",
|
||||
}));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "bump a to 10");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "bump a to 10");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "a changed fence is one atomic proposal");
|
||||
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0])!;
|
||||
@@ -77,15 +80,15 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
|
||||
test("accepting a block proposal attributes only the changed words to Claude (INV-40)", async () => {
|
||||
const doc = await freshDoc("docs/f12-attr.md", "# T\n\nThe quick brown fox jumps lazily.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
const editFlow = api.editFlow;
|
||||
const key = api.proposalController.keyFor(doc);
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# T\n\nThe quick RED fox jumps SLOWLY.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f12-attr",
|
||||
}));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "change two words");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "change two words");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "one changed paragraph → one block proposal");
|
||||
|
||||
@@ -115,11 +118,11 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
|
||||
const rewrite = "# Ins\n\nAlpha block.\n\nBrand new middle block.\n\nBeta block.\n";
|
||||
const doc = await freshDoc("docs/f12-insert.md", original);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
const editFlow = api.editFlow;
|
||||
const key = api.proposalController.keyFor(doc);
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph");
|
||||
editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" }));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph");
|
||||
await settle();
|
||||
assert.ok(ids.length >= 1, "the insertion produced at least one proposal");
|
||||
|
||||
@@ -132,3 +135,55 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
|
||||
});
|
||||
});
|
||||
|
||||
// Task 8 (PUC-2 lens copy): the CodeLens titles read "✓ Keep"/"✗ Reject" per
|
||||
// proposal, and — once ≥2 proposals are pending — a top-of-file "Keep all
|
||||
// (N)"/"Reject all" pair routes straight at the batch commands.
|
||||
suite("F12 CodeLens copy (Task 8, PUC-2)", () => {
|
||||
test("one pending proposal → a per-proposal ✓ Keep / ✗ Reject pair, no top-of-file pair", async () => {
|
||||
const doc = await freshDoc("docs/f12-lens-one.md", "# Lens\n\nAlpha block here.\n");
|
||||
const api = await getApi();
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# Lens\n\nALPHA block here.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-lens-one",
|
||||
}));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "one changed block → one proposal");
|
||||
|
||||
const lenses = api.editorProposalController.provideCodeLenses(doc);
|
||||
const titles = lenses.map((l) => l.command?.title);
|
||||
assert.deepStrictEqual(titles, ["✓ Keep", "✗ Reject"], "exactly one per-proposal pair, no top-of-file pair below threshold");
|
||||
});
|
||||
|
||||
test("two pending proposals → a top-of-file 'Keep all (2)'/'Reject all' pair routed at the batch commands", async () => {
|
||||
const doc = await freshDoc(
|
||||
"docs/f12-lens-two.md",
|
||||
"# Lens\n\nFirst block here.\n\nSecond block here.\n",
|
||||
);
|
||||
const api = await getApi();
|
||||
const editFlow = api.editFlow;
|
||||
editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: "# Lens\n\nFIRST block here.\n\nSECOND block here.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-lens-two",
|
||||
}));
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase both");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 2, "two changed blocks → two proposals");
|
||||
|
||||
const lenses = api.editorProposalController.provideCodeLenses(doc);
|
||||
const titles = lenses.map((l) => l.command?.title);
|
||||
assert.strictEqual(titles[0], "✓ Keep all (2)", "top-of-file accept-all lens leads");
|
||||
assert.strictEqual(titles[1], "✗ Reject all", "top-of-file reject-all lens follows");
|
||||
assert.deepStrictEqual(titles.slice(2), ["✓ Keep", "✗ Reject", "✓ Keep", "✗ Reject"], "per-proposal pairs follow");
|
||||
|
||||
const acceptAll = lenses[0].command!;
|
||||
const rejectAll = lenses[1].command!;
|
||||
assert.strictEqual(acceptAll.command, "cowriting.acceptAllProposals", "top-of-file accept-all routes at the batch command");
|
||||
assert.strictEqual(rejectAll.command, "cowriting.rejectAllProposals", "top-of-file reject-all routes at the batch command");
|
||||
assert.deepStrictEqual(acceptAll.arguments, undefined, "acceptAllProposals takes no arguments (targets the active editor)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import * as assert from "node:assert";
|
||||
import * as vscode from "vscode";
|
||||
import { activateApi, settle, settleUntil } from "./helpers";
|
||||
|
||||
/**
|
||||
* Task 9 (spec §6.8 named E2E, §7.1 rung 2 — stub-turned): the full
|
||||
* native-surfaces inner loop end to end, in ONE doc:
|
||||
* PUC-7 (enter coediting, snapshot baseline)
|
||||
* → PUC-8 (comment → reply → offer → pending proposal, D10/D19)
|
||||
* → PUC-2 (buffer review: tweak under typing / re-anchor, keep, reject)
|
||||
* → PUC-1 (pin the baseline clean).
|
||||
* Both turns are stubbed (no real @cline/sdk call) — the real-SDK rung 3 is
|
||||
* `scripts/smoke-native-loop.mjs`, operator-run against live credentials.
|
||||
*/
|
||||
suite("full native loop — PUC-7 → PUC-8 → PUC-2 → PUC-1 (§6.8, §7.1 rung 2)", () => {
|
||||
test("enter, ask twice, offer, tweak-under-typing, keep one, reject one, pin — clean review", async () => {
|
||||
const api = await activateApi();
|
||||
const ORIG_KEEP = "The quick brown fox jumps over the lazy dog paragraph.";
|
||||
const ORIG_REJECT = "A second paragraph that should stay exactly as written.";
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
language: "markdown",
|
||||
content: `# Full loop\n\n${ORIG_KEEP}\n\n${ORIG_REJECT}\n`,
|
||||
});
|
||||
const ed = await vscode.window.showTextDocument(doc);
|
||||
|
||||
// PUC-7: enter coediting. An untitled doc has no git HEAD, so it lands in
|
||||
// snapshot mode (INV-7/D13/D14 — baselineRouter.test.ts covers the fork).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
assert.strictEqual(
|
||||
api.diffViewController.modeOf(doc.uri.toString()),
|
||||
"snapshot",
|
||||
"untitled doc enters coediting in snapshot mode",
|
||||
);
|
||||
|
||||
const docKey = api.proposalController.keyFor(doc);
|
||||
const KEEP_REPLACEMENT = "The quick fox jumps the lazy dog.";
|
||||
const REJECT_REPLACEMENT = "A rewritten second paragraph nobody asked to keep.";
|
||||
|
||||
// ---- PUC-8, first ask: the "keep" paragraph -------------------------------------
|
||||
api.threadController.setTurnRunnerForTest(async () => ({
|
||||
replacement: "I'd tighten this sentence.",
|
||||
model: "stub",
|
||||
sessionId: "full-loop-1",
|
||||
}));
|
||||
api.editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: KEEP_REPLACEMENT,
|
||||
model: "stub",
|
||||
sessionId: "full-loop-1",
|
||||
}));
|
||||
const keepStart = doc.getText().indexOf(ORIG_KEEP);
|
||||
ed.selection = new vscode.Selection(doc.positionAt(keepStart), doc.positionAt(keepStart + ORIG_KEEP.length));
|
||||
const keepThreadId = (await api.threadController.createThreadOnSelection("tighten this"))!;
|
||||
assert.ok(keepThreadId, "thread created on a coedited doc (INV-10 gate open)");
|
||||
// The reply loop fires on the human message; wait for the machine reply to
|
||||
// land and flip the thread to "offer" (D10/PUC-8).
|
||||
await settleUntil(() => {
|
||||
const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === keepThreadId);
|
||||
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
|
||||
}, 10000);
|
||||
const keepIds = await api.threadController.makeThreadEdit(keepThreadId, docKey);
|
||||
assert.strictEqual(keepIds.length, 1, "one pending proposal from the ranged ask");
|
||||
const keepId = keepIds[0];
|
||||
|
||||
// ---- PUC-8, second ask: the "reject" paragraph ----------------------------------
|
||||
api.editFlow.setEditTurnForTest(async () => ({
|
||||
replacement: REJECT_REPLACEMENT,
|
||||
model: "stub",
|
||||
sessionId: "full-loop-2",
|
||||
}));
|
||||
const rejectStart = doc.getText().indexOf(ORIG_REJECT);
|
||||
ed.selection = new vscode.Selection(doc.positionAt(rejectStart), doc.positionAt(rejectStart + ORIG_REJECT.length));
|
||||
const rejectThreadId = (await api.threadController.createThreadOnSelection("rewrite this"))!;
|
||||
await settleUntil(() => {
|
||||
const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === rejectThreadId);
|
||||
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
|
||||
}, 10000);
|
||||
const rejectIds = await api.threadController.makeThreadEdit(rejectThreadId, docKey);
|
||||
assert.strictEqual(rejectIds.length, 1, "one pending proposal from the second ranged ask");
|
||||
const rejectId = rejectIds[0];
|
||||
|
||||
// ---- decorated pending proposals (F12 optimistic-apply) --------------------------
|
||||
await settleUntil(
|
||||
() => api.proposalController.isApplied(docKey, keepId) && api.proposalController.isApplied(docKey, rejectId),
|
||||
5000,
|
||||
);
|
||||
let views = api.proposalController.listProposals(doc);
|
||||
assert.strictEqual(views.length, 2, "two pending proposals decorated");
|
||||
for (const id of [keepId, rejectId]) {
|
||||
const v = views.find((x) => x.id === id)!;
|
||||
assert.notStrictEqual(v.anchorStart, null, "each proposal resolves (pending, not orphaned)");
|
||||
assert.ok(api.proposalController.isApplied(docKey, id), "optimistically applied into the buffer");
|
||||
}
|
||||
assert.ok(doc.getText().includes(KEEP_REPLACEMENT), "claude's first proposed rewrite is live in the buffer");
|
||||
assert.ok(doc.getText().includes(REJECT_REPLACEMENT), "claude's second proposed rewrite is live in the buffer");
|
||||
|
||||
// ---- PUC-2, tweak: type INSIDE the still-pending "keep" range --------------------
|
||||
// Verified against src/anchorer.ts: `resolve()` is an EXACT-substring search
|
||||
// (INV-1 — no fuzzy tolerance), so an interior edit to a proposal's own
|
||||
// fingerprinted text unconditionally breaks that fingerprint — this is INV-11,
|
||||
// already covered by proposals.test.ts's "editing the target text makes the
|
||||
// proposal stale" case. F12's finalizeInPlace ("Keep") is deliberately built to
|
||||
// bypass that staleness check (see its own doc comment: "Direct finalizeInPlace
|
||||
// calls ... where the user may have edited inside the applied span bypass this
|
||||
// check intentionally") — the tweaked text is ALREADY the accepted result
|
||||
// sitting in the buffer, so no re-resolve is needed to keep it. That is the
|
||||
// real "re-anchor" story for a pending-range tweak: the proposal survives as a
|
||||
// live, addressable id (not silently dropped) and Keep still lands it, even
|
||||
// though its raw anchor is (correctly) stale.
|
||||
const at = doc.getText().indexOf(KEEP_REPLACEMENT);
|
||||
const tweak = new vscode.WorkspaceEdit();
|
||||
tweak.replace(doc.uri, new vscode.Range(doc.positionAt(at + 10), doc.positionAt(at + 13)), "FOX"); // "fox" -> "FOX"
|
||||
assert.ok(await vscode.workspace.applyEdit(tweak), "human tweak applied inside the pending range");
|
||||
await settle();
|
||||
assert.ok(doc.getText().includes("The quick FOX jumps the lazy dog."), "human tweak landed in the buffer");
|
||||
views = api.proposalController.listProposals(doc);
|
||||
const keepView = views.find((v) => v.id === keepId);
|
||||
assert.ok(keepView, "the tweaked proposal is still a live, addressable pending proposal (not silently dropped)");
|
||||
assert.strictEqual(
|
||||
keepView!.anchorStart,
|
||||
null,
|
||||
"INV-11: the interior tweak breaks the exact-fingerprint anchor (flagged stale, never guessed)",
|
||||
);
|
||||
|
||||
// ---- PUC-2, keep: finalize the tweaked proposal in place -------------------------
|
||||
assert.ok(await api.proposalController.finalizeInPlace(docKey, keepId), "keep finalizes in place");
|
||||
await settle();
|
||||
assert.strictEqual(
|
||||
api.proposalController.listProposals(doc).find((v) => v.id === keepId),
|
||||
undefined,
|
||||
"kept proposal cleared",
|
||||
);
|
||||
// Attribution split (F3xF12): Claude's words AND the human tweak are both
|
||||
// attributed, char-honest (spansFor).
|
||||
const spans = api.attributionController.spansFor(doc);
|
||||
const claudeSpan = spans.find(
|
||||
(s) => s.author === "claude" && doc.getText().slice(s.start, s.end).includes("jumps the lazy dog"),
|
||||
);
|
||||
const humanSpan = spans.find((s) => s.author === "human" && doc.getText().slice(s.start, s.end) === "FOX");
|
||||
assert.ok(claudeSpan, "claude's words are attributed");
|
||||
assert.ok(humanSpan, "the human tweak is attributed separately (char-honest split)");
|
||||
|
||||
// ---- PUC-2, reject: revert the second proposal in place --------------------------
|
||||
// #70 (INV-5): the reject leg now ALSO takes an interior tweak first — the
|
||||
// original deliberately rejected only an un-tweaked proposal because the
|
||||
// pre-fix revert silently skipped an orphaned anchor. The tweak orphans the
|
||||
// exact anchor; the revert must restore ORIG_REJECT via the tracked span.
|
||||
const rejAt = doc.getText().indexOf(REJECT_REPLACEMENT);
|
||||
assert.ok(rejAt >= 0, "reject proposal's applied text present");
|
||||
const rejTweak = new vscode.WorkspaceEdit();
|
||||
rejTweak.replace(doc.uri, new vscode.Range(doc.positionAt(rejAt + 2), doc.positionAt(rejAt + 2)), "x");
|
||||
assert.ok(await vscode.workspace.applyEdit(rejTweak), "human tweak inside the reject proposal's range");
|
||||
await settle();
|
||||
assert.strictEqual(
|
||||
api.proposalController.listProposals(doc).find((v) => v.id === rejectId)!.anchorStart,
|
||||
null,
|
||||
"tweak orphans the reject proposal's exact anchor (INV-11)",
|
||||
);
|
||||
assert.ok(await api.proposalController.revertInPlace(docKey, rejectId), "reject reverts in place");
|
||||
await settle();
|
||||
assert.strictEqual(
|
||||
api.proposalController.listProposals(doc).find((v) => v.id === rejectId),
|
||||
undefined,
|
||||
"rejected proposal cleared",
|
||||
);
|
||||
// INV-5: the rejected range returns EXACTLY to its pre-proposal text.
|
||||
assert.ok(doc.getText().includes(ORIG_REJECT), "rejected range restored EXACTLY to its pre-proposal text (INV-5)");
|
||||
assert.ok(!doc.getText().includes(REJECT_REPLACEMENT), "claude's rejected rewrite is gone from the buffer");
|
||||
|
||||
// ---- PUC-1: pin the baseline (Mark Changes as Reviewed) --------------------------
|
||||
await vscode.commands.executeCommand("cowriting.markReviewed");
|
||||
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 0, 5000);
|
||||
assert.strictEqual(
|
||||
api.diffViewController.getBaseline(doc.uri.toString())?.reason,
|
||||
"pinned",
|
||||
"baseline re-pinned to the clean, fully-reviewed buffer",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Shared host-E2E helpers (native-surfaces migration, Task 2). NEW suites use
|
||||
* these; existing suites keep their own local copies (out of scope to
|
||||
* refactor them onto this shared file — see the Task 2 brief).
|
||||
*/
|
||||
import * as assert from "node:assert";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { renderReview } from "../../../src/trackChangesModel";
|
||||
|
||||
/** Activate the extension and return its exported API (asserts it is real). */
|
||||
export async function activateApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.diffViewController, "extension exports diffViewController");
|
||||
return api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 8: the pure render probe the deleted TrackChangesPreviewController used
|
||||
* to wrap (`renderHtmlFor`) — the sunset webview was a thin shell around this
|
||||
* exact call (F6 baseline + F3 spans + F4 pending proposals -> `renderReview`).
|
||||
* Suites that used to read `controller.renderHtmlFor(key)` now call this
|
||||
* directly against the surviving controllers; no webview involved.
|
||||
*/
|
||||
export function renderHtmlFor(api: CowritingApi, doc: vscode.TextDocument, key: string): string {
|
||||
const baseline = api.diffViewController.getBaseline(key);
|
||||
const current = doc.getText();
|
||||
return renderReview(
|
||||
baseline?.text ?? current,
|
||||
current,
|
||||
api.attributionController.spansFor(doc),
|
||||
api.proposalController.listProposals(doc),
|
||||
{ pinned: baseline?.reason === "pinned" },
|
||||
);
|
||||
}
|
||||
|
||||
/** A short fixed settle, for state that updates synchronously-ish after a command. */
|
||||
export function settle(): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
|
||||
/** Poll `predicate` every 100ms until it is true, or fail after `timeoutMs`. */
|
||||
export async function settleUntil(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
assert.fail(`settleUntil: predicate did not become true within ${timeoutMs}ms`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController, "exports preview controller");
|
||||
assert.ok(api?.editFlow && api?.proposalController, "exports edit flow + proposal controller");
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument>
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: proposal rendering (F12 optimistic-apply, driven by
|
||||
// onDidChangeProposals) is gated on coediting (Task 4).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return doc;
|
||||
}
|
||||
@@ -33,37 +36,36 @@ suite("#60 live turn progress (additive + cancel)", () => {
|
||||
test("a stub that emits progress still produces the same proposals (INV-44)", async () => {
|
||||
const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
// The stub honors opts.onProgress (emitting synthetic snapshots) but returns
|
||||
// the same rewrite — proposals must be unaffected by progress events.
|
||||
ctl.setEditTurnForTest(async (_i, _text, opts) => {
|
||||
editFlow.setEditTurnForTest(async (_i, _text, opts) => {
|
||||
opts?.onProgress?.({ phase: "writing", chars: 5 });
|
||||
opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." });
|
||||
return { replacement: "# Title\n\nNew paragraph.\n", model: "sonnet", sessionId: "e2e-live60" };
|
||||
});
|
||||
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph");
|
||||
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events");
|
||||
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]);
|
||||
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 () => {
|
||||
const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
const editFlow = api.editFlow;
|
||||
|
||||
// The stub throws ONLY when the aborted signal reached it — so if opts.signal
|
||||
// failed to thread through runEditAndPropose, the stub would instead return a
|
||||
// rewrite and create a proposal, failing this test. That proves propagation.
|
||||
ctl.setEditTurnForTest(async (_i, _text, opts) => {
|
||||
editFlow.setEditTurnForTest(async (_i, _text, opts) => {
|
||||
if (opts?.signal?.aborted) throw new Error("claude-code turn aborted");
|
||||
return { replacement: "# Title\n\nSHOULD NOT BE PROPOSED.\n", model: "sonnet", sessionId: "e2e-live60-nope" };
|
||||
});
|
||||
@@ -72,7 +74,7 @@ suite("#60 live turn progress (additive + cancel)", () => {
|
||||
ac.abort();
|
||||
let ids: string[] = [];
|
||||
try {
|
||||
ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
|
||||
ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
|
||||
} catch {
|
||||
ids = [];
|
||||
}
|
||||
|
||||
@@ -37,8 +37,16 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)",
|
||||
const uri = vscode.Uri.file(outsidePath);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: attribution tracking + thread creation are gated on coediting (Task 4).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
// Task 6 (D10): createThreadOnSelection (used later in this test) also
|
||||
// fires the respond-in-thread turn now — stub it off (this "no LLM" suite
|
||||
// asserts the programmatic propose/accept seam, not the reply loop).
|
||||
api.threadController.setTurnRunnerForTest(async () => {
|
||||
throw new Error("out-of-workspace suite: reply-loop turn stubbed off");
|
||||
});
|
||||
const key = uri.toString();
|
||||
|
||||
const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1");
|
||||
@@ -80,6 +88,8 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)",
|
||||
const TARGET = "The untitled draft sentence.";
|
||||
const untitled = await vscode.workspace.openTextDocument({ content: `${TARGET}\n`, language: "markdown" });
|
||||
await vscode.window.showTextDocument(untitled);
|
||||
// INV-10: attribution tracking is gated on coediting (Task 4).
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
const key = untitled.uri.toString();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as assert from "node:assert";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import * as vscode from "vscode";
|
||||
import { cowritingMarkdownItPlugin } from "../../../src/previewAnnotations";
|
||||
import { activateApi, settle } from "./helpers";
|
||||
|
||||
// Task 7 (D3/D21, PUC-3): the built-in preview's DOM isn't queryable from a
|
||||
// host E2E test (§6.8), so this asserts the pure transform's INPUTS/OUTPUTS
|
||||
// through the real `CowritingApi` seams instead — the production
|
||||
// `previewAnnotationHost.inputsFor` (not a hand-rolled reimplementation) fed
|
||||
// through the real `cowritingMarkdownItPlugin` + a fresh markdown-it instance
|
||||
// (mirroring the unit suite's own `render()` helper), so this exercises the
|
||||
// ACTUAL registry/diffView/attribution/proposals/config wiring end to end.
|
||||
suite("PUC-3 annotate-toggle (built-in Markdown preview annotations)", () => {
|
||||
test("a machine-attributed change renders cw-ins-claude; disabling the setting passes the source through unchanged", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
language: "markdown",
|
||||
content: "hello world\n",
|
||||
});
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
|
||||
const start = doc.getText().indexOf("world");
|
||||
const ok = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
|
||||
uri: doc.uri.toString(),
|
||||
start,
|
||||
end: start + "world".length,
|
||||
newText: "brave new world",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-preview-annotations",
|
||||
turnId: "turn-e2e-preview-annotations",
|
||||
});
|
||||
assert.strictEqual(ok, true, "seam edit applies");
|
||||
await settle();
|
||||
|
||||
// Annotations ON (default): render the CURRENT buffer through the real
|
||||
// markdown-it plugin + the production host — proves the full pipeline
|
||||
// (core-rule swap → full-render sentinel walk) reaches a live doc.
|
||||
const md = new MarkdownIt();
|
||||
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
|
||||
const html = md.render(doc.getText());
|
||||
assert.ok(html.includes("cw-ins-claude"), `expected cw-ins-claude in:\n${html}`);
|
||||
|
||||
// Toggle OFF: the config-gated `enabled` flag reaches `annotateSource` and
|
||||
// the transform becomes the identity (no core-rule swap, no marks).
|
||||
const config = vscode.workspace.getConfiguration("cowriting");
|
||||
await config.update("annotations", false, vscode.ConfigurationTarget.Global);
|
||||
try {
|
||||
const inputs = api.previewAnnotationHost.inputsFor(undefined);
|
||||
assert.ok(inputs, "inputsFor still resolves a coedited doc while disabled");
|
||||
assert.strictEqual(inputs!.enabled, false);
|
||||
const md2 = new MarkdownIt();
|
||||
cowritingMarkdownItPlugin(md2, api.previewAnnotationHost);
|
||||
const plainMd = new MarkdownIt();
|
||||
assert.strictEqual(md2.render(doc.getText()), plainMd.render(doc.getText()));
|
||||
} finally {
|
||||
await config.update("annotations", true, vscode.ConfigurationTarget.Global);
|
||||
}
|
||||
});
|
||||
|
||||
test("cowriting.toggleAnnotations flips the setting", async () => {
|
||||
const api = await activateApi();
|
||||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "toggle me\n" });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
|
||||
const config = () => vscode.workspace.getConfiguration("cowriting");
|
||||
const before = config().get<boolean>("annotations", true);
|
||||
await vscode.commands.executeCommand("cowriting.toggleAnnotations");
|
||||
assert.strictEqual(config().get<boolean>("annotations", true), !before);
|
||||
// restore
|
||||
await vscode.commands.executeCommand("cowriting.toggleAnnotations");
|
||||
assert.strictEqual(config().get<boolean>("annotations", true), before);
|
||||
void api; // keep the activated extension alive for the duration of the test
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,10 @@ async function openDoc(): Promise<vscode.TextDocument> {
|
||||
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: proposal rendering (and the F12 optimistic-apply it drives via
|
||||
// onDidChangeProposals) is gated on coediting (Task 4) — idempotent across
|
||||
// this order-dependent suite's repeated openDoc() calls.
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
return doc;
|
||||
}
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
@@ -63,13 +67,14 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
||||
const TARGET = "The propose target sentence lives here.";
|
||||
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 api = await getApi();
|
||||
const before = doc.getText();
|
||||
await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1");
|
||||
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);
|
||||
assert.strictEqual(rendered.length, 1);
|
||||
assert.strictEqual(rendered[0].pending, true);
|
||||
@@ -77,7 +82,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)");
|
||||
const art = readSidecar();
|
||||
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 () => {
|
||||
@@ -97,15 +103,18 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
||||
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 api = await getApi();
|
||||
const before = doc.getText();
|
||||
const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2");
|
||||
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();
|
||||
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(readSidecar().proposals.length, 0);
|
||||
});
|
||||
@@ -114,25 +123,35 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
||||
let doc = await openDoc();
|
||||
const api = await getApi();
|
||||
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();
|
||||
// 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));
|
||||
// doc.getText() now has appliedText; the reload prepends a line.
|
||||
doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText());
|
||||
await settle();
|
||||
api.proposalController.renderAll(doc);
|
||||
const rendered = api.proposalController.getRendered(DOC_REL);
|
||||
assert.strictEqual(rendered.length, 1, "proposal survived reload");
|
||||
assert.strictEqual(rendered[0].pending, true, "still decidable");
|
||||
const moved = doc.getText().indexOf(anchor);
|
||||
assert.strictEqual(rendered[0].range.start, moved, "re-anchored after the move");
|
||||
// After F12 re-anchor, the fingerprint tracks the appliedText, not the original anchor.
|
||||
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 () => {
|
||||
let doc = await openDoc();
|
||||
const api = await getApi();
|
||||
const id = api.proposalController.getRendered(DOC_REL)[0].id;
|
||||
const mangled = doc.getText().replace("A stable closing paragraph.", "A reworded closing paragraph.");
|
||||
doc = await externalWriteAndReload(vscode.Uri.file(path.join(WS, DOC_REL)), mangled);
|
||||
// After F12 optimistic apply the fingerprint tracks the APPLIED text ("A PROPOSED
|
||||
// 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();
|
||||
api.proposalController.renderAll(doc);
|
||||
assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale");
|
||||
@@ -143,22 +162,30 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
||||
assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)");
|
||||
// discard the husk so later tests see a clean sidecar
|
||||
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 () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4");
|
||||
const id2 = await proposeViaCommand(doc, "PROPOSED-BY-CLAUDE replacement sentence.", "A REPLACED-AGAIN sentence.", "turn-p5");
|
||||
// Clean up any proposals left over from prior tests (F12: proposals persist in state).
|
||||
await api.proposalController.rejectAll(doc);
|
||||
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
|
||||
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true);
|
||||
await settle();
|
||||
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true);
|
||||
await settle();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController, "exports preview controller");
|
||||
assert.ok(api?.coeditingRegistry, "exports coeditingRegistry");
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -26,14 +26,19 @@ function pkg(): any {
|
||||
return JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||
}
|
||||
|
||||
// #41 (story): "Open Cowriting Review Panel" reachable from the markdown
|
||||
// file/tab right-click menu. The review surface (showTrackChangesPreview) is the
|
||||
// plugin's central affordance; this adds explorer/context + editor/title/context
|
||||
// entry points and makes the command open the *clicked* document — not merely
|
||||
// the active editor — so the explorer right-click works even when the file is
|
||||
// not already open.
|
||||
suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-doc resolution)", () => {
|
||||
test("command with a Uri previews the CLICKED doc, not the active editor", async () => {
|
||||
// Task 8 (native-surfaces migration, spec §6.10): the review-webview's #41
|
||||
// right-click entries ("Open Cowriting Review Panel") are replaced by
|
||||
// `cowriting.openReviewPreview` — a minimal wrapper that resolves the clicked
|
||||
// document (or falls back to the active editor, mirroring #41's original
|
||||
// behavior), enters coediting if not already (so there is a baseline to review
|
||||
// against), and hands off to VS Code's OWN "Open Preview to the Side"
|
||||
// (`markdown.showPreviewToSide`) — the built-in preview IS the review surface
|
||||
// now (Task 7's annotations render inside it). There is no controller-level
|
||||
// `isOpen` seam anymore (no bespoke webview to introspect); these tests observe
|
||||
// the command's OWN side effects instead — which document it focused/entered
|
||||
// coediting on — the same signal #41's original suite checked via `isOpen`.
|
||||
suite("Open Cowriting Review Preview (host E2E — menu wiring + clicked-doc resolution, Task 8)", () => {
|
||||
test("command with a Uri targets the CLICKED doc, not the active editor", async () => {
|
||||
// Two markdown docs; A is the active editor, B is the right-clicked target.
|
||||
const aUri = writeFile("docs/menu-active.md", "# Active\n\nThe active editor's document.\n");
|
||||
const bUri = writeFile("docs/menu-clicked.md", "# Clicked\n\nThe right-clicked document.\n");
|
||||
@@ -43,19 +48,20 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview", bUri);
|
||||
await vscode.commands.executeCommand("cowriting.openReviewPreview", bUri);
|
||||
await settle();
|
||||
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(bUri.toString()), true, "preview opened for the clicked doc");
|
||||
assert.strictEqual(
|
||||
api.trackChangesPreviewController.isOpen(aUri.toString()),
|
||||
false,
|
||||
"the active editor's doc did NOT get a preview",
|
||||
);
|
||||
// `markdown.showPreviewToSide` itself ends with the preview WEBVIEW focused
|
||||
// (not a text editor), so `activeTextEditor` is not a stable post-command
|
||||
// signal — `isCoediting` (set by the command's OWN resolve-and-enter step,
|
||||
// before it hands off to the preview) is: the clicked doc B entered, the
|
||||
// still-open-but-not-clicked doc A did not.
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(bUri), true, "the clicked doc B entered coediting (a baseline to review against)");
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(aUri), false, "the active-but-not-clicked doc A did NOT enter coediting");
|
||||
assert.ok(bDoc, "clicked doc handle held");
|
||||
});
|
||||
|
||||
test("command with a Uri for a not-yet-open file opens it and previews (explorer case)", async () => {
|
||||
test("command with a Uri for a not-yet-open file opens it and focuses it (explorer case)", async () => {
|
||||
// Write a file but do not openTextDocument it — mimics an Explorer right-click.
|
||||
const uri = writeFile("docs/menu-unopened.md", "# Unopened\n\nNever opened before the right-click.\n");
|
||||
const key = uri.toString();
|
||||
@@ -65,10 +71,14 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
|
||||
"precondition: the file is not an open document",
|
||||
);
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview", uri);
|
||||
await vscode.commands.executeCommand("cowriting.openReviewPreview", uri);
|
||||
await settle();
|
||||
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "the clicked-but-unopened file got a preview");
|
||||
assert.ok(
|
||||
vscode.workspace.textDocuments.some((d) => d.uri.toString() === key),
|
||||
"the clicked-but-unopened file was opened",
|
||||
);
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), true, "it entered coediting");
|
||||
});
|
||||
|
||||
test("no-arg invocation still falls back to the active editor (palette / keybinding unchanged)", async () => {
|
||||
@@ -78,39 +88,74 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await vscode.commands.executeCommand("cowriting.openReviewPreview");
|
||||
await settle();
|
||||
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(uri.toString()), true, "no-arg previews the active editor");
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), true, "no-arg targets the active editor");
|
||||
});
|
||||
|
||||
test("explorer/context contributes the review-panel item for markdown only", () => {
|
||||
test("a non-markdown doc: command warns, no coediting entered", async () => {
|
||||
const uri = writeFile("docs/menu-notmd.txt", "Not markdown.\n");
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
assert.notStrictEqual(doc.languageId, "markdown", "fixture is not markdown");
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.openReviewPreview");
|
||||
await settle();
|
||||
|
||||
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), false, "a non-markdown doc never enters coediting via this gateway");
|
||||
});
|
||||
|
||||
test("explorer/context contributes the review-preview item for markdown only", () => {
|
||||
const items = (pkg().contributes.menus["explorer/context"] ?? []) as Array<{ command: string; when?: string }>;
|
||||
const entry = items.find((m) => m.command === "cowriting.showTrackChangesPreview");
|
||||
assert.ok(entry, "explorer/context has a showTrackChangesPreview entry");
|
||||
const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
|
||||
assert.ok(entry, "explorer/context has an openReviewPreview entry");
|
||||
assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown resources");
|
||||
});
|
||||
|
||||
test("editor/title/context (tab right-click) contributes the review-panel item for markdown only", () => {
|
||||
test("editor/title/context (tab right-click) contributes the review-preview item for markdown only", () => {
|
||||
const items = (pkg().contributes.menus["editor/title/context"] ?? []) as Array<{ command: string; when?: string }>;
|
||||
const entry = items.find((m) => m.command === "cowriting.showTrackChangesPreview");
|
||||
assert.ok(entry, "editor/title/context has a showTrackChangesPreview entry");
|
||||
const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
|
||||
assert.ok(entry, "editor/title/context has an openReviewPreview entry");
|
||||
assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown tabs");
|
||||
});
|
||||
|
||||
test("the command title reads 'Open Cowriting Review Panel'", () => {
|
||||
const cmd = (pkg().contributes.commands as Array<{ command: string; title: string }>).find(
|
||||
(c) => c.command === "cowriting.showTrackChangesPreview",
|
||||
);
|
||||
assert.ok(cmd, "command is contributed");
|
||||
assert.strictEqual(cmd!.title, "Open Cowriting Review Panel", "menus render this title");
|
||||
test("editor/title (toolbar icon) contributes the review-preview item for markdown only", () => {
|
||||
const items = (pkg().contributes.menus["editor/title"] ?? []) as Array<{ command: string; when?: string }>;
|
||||
const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
|
||||
assert.ok(entry, "editor/title has an openReviewPreview entry");
|
||||
assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown");
|
||||
});
|
||||
|
||||
test("the ctrl+alt+r keybinding still targets the command (unchanged)", () => {
|
||||
const kb = (pkg().contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find(
|
||||
test("the command title reads 'Open Cowriting Review Preview'", () => {
|
||||
const cmd = (pkg().contributes.commands as Array<{ command: string; title: string }>).find(
|
||||
(c) => c.command === "cowriting.openReviewPreview",
|
||||
);
|
||||
assert.ok(cmd, "command is contributed");
|
||||
assert.strictEqual(cmd!.title, "Open Cowriting Review Preview", "menus render this title");
|
||||
});
|
||||
|
||||
test("the old cowriting.showTrackChangesPreview command/menus/keybinding are fully gone (Task 8 exit criterion)", () => {
|
||||
const p = pkg();
|
||||
const commands = p.contributes.commands as Array<{ command: string }>;
|
||||
assert.ok(!commands.some((c) => c.command === "cowriting.showTrackChangesPreview"), "command contribution removed");
|
||||
for (const menuId of ["commandPalette", "editor/title", "editor/title/context", "explorer/context"]) {
|
||||
const items = (p.contributes.menus[menuId] ?? []) as Array<{ command: string }>;
|
||||
assert.ok(!items.some((m) => m.command === "cowriting.showTrackChangesPreview"), `${menuId} no longer references it`);
|
||||
}
|
||||
const kb = (p.contributes.keybindings as Array<{ command: string }>).find(
|
||||
(k) => k.command === "cowriting.showTrackChangesPreview",
|
||||
);
|
||||
assert.ok(kb, "keybinding still present");
|
||||
assert.strictEqual(kb!.key, "ctrl+alt+r", "key unchanged");
|
||||
assert.ok(!kb, "keybinding removed");
|
||||
});
|
||||
|
||||
test("ctrl+alt+r now targets cowriting.reviewChanges (the native diff), not the retired preview command", () => {
|
||||
const kb = (pkg().contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find(
|
||||
(k) => k.key === "ctrl+alt+r",
|
||||
);
|
||||
assert.ok(kb, "ctrl+alt+r keybinding still present");
|
||||
assert.strictEqual(kb!.command, "cowriting.reviewChanges", "the chord now opens the native diff (Step 1 re-point)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { renderHtmlFor } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
@@ -10,10 +11,12 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController, "exports preview controller");
|
||||
assert.ok(api?.diffViewController && api?.attributionController, "exports diffView + attribution");
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Also enters coediting (INV-10/Task 2): the baseline is established only on
|
||||
* entry, not merely on open. */
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
@@ -21,6 +24,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
@@ -32,24 +36,22 @@ suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
|
||||
test("pin clears authorship coloring; a later edit brings annotations back", async () => {
|
||||
const { doc, key } = await freshDoc("docs/s48pin.md", "# S48\n\nAn original baseline paragraph.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
// Type a paragraph → a human attribution span + author coloring in the on-state.
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||
await settle();
|
||||
assert.match(ctl.renderHtmlFor(key), /cw-by-human/, "typed text is author-colored before the pin");
|
||||
assert.match(renderHtmlFor(api, doc, key), /cw-ins-human/, "typed text is author-colored cw-ins-human before the pin");
|
||||
|
||||
// Pin the baseline → zero diff → the panel must be fully clean.
|
||||
ctl.receiveMessage(key, { type: "pinBaseline" });
|
||||
// Pin the baseline (the real command — replaces the F11-webview's pinBaseline
|
||||
// toolbar intent, Task 8) → zero diff → the render must be fully clean.
|
||||
await vscode.commands.executeCommand("cowriting.markReviewed");
|
||||
await settle();
|
||||
const pinned = ctl.renderHtmlFor(key);
|
||||
assert.ok(!pinned.includes("cw-by-human"), "no human authorship coloring after pin");
|
||||
assert.ok(!pinned.includes("cw-by-claude"), "no Claude authorship coloring after pin");
|
||||
assert.ok(!pinned.includes("cw-add") && !pinned.includes("cw-del"), "no change marks after pin");
|
||||
const pinned = renderHtmlFor(api, doc, key);
|
||||
assert.ok(!pinned.includes("cw-ins-human"), "no human insertion marks after pin");
|
||||
assert.ok(!pinned.includes("cw-ins-claude"), "no Claude insertion marks after pin");
|
||||
assert.ok(!pinned.includes("cw-ins-") && !pinned.includes("cw-del-"), "no insertion or deletion marks after pin");
|
||||
assert.match(pinned, /data-src-start/, "blocks still carry data-src offsets (INV-36 mapping kept)");
|
||||
assert.ok(pinned.includes("freshly typed human paragraph"), "the body text is still rendered, just plain");
|
||||
|
||||
@@ -58,6 +60,6 @@ suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
|
||||
edit2.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA second typed paragraph diverges again.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit2), "second operator edit applied");
|
||||
await settle();
|
||||
assert.match(ctl.renderHtmlFor(key), /cw-by-human/, "authorship coloring returns once the doc diverges from the pin");
|
||||
assert.match(renderHtmlFor(api, doc, key), /cw-ins-human/, "cw-ins-human authorship coloring returns once the doc diverges from the pin");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,9 @@ async function openSample(): Promise<vscode.TextDocument> {
|
||||
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
// INV-10: thread creation/rendering is gated on coediting (Task 4) — idempotent
|
||||
// across this order-dependent suite's repeated openSample() calls.
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
return doc;
|
||||
}
|
||||
|
||||
@@ -38,6 +41,17 @@ async function getApi(): Promise<CowritingApi> {
|
||||
return api;
|
||||
}
|
||||
|
||||
// Task 6 (D10): every human comment on a coedited doc now also fires the
|
||||
// respond-in-thread turn (createThreadOnSelection/reply). This suite predates
|
||||
// that loop and asserts exact message sequences unrelated to it — stub the
|
||||
// turn to reject so the loop runs harmlessly (no real @cline/sdk call, and no
|
||||
// extra machine message lands in the sidecar this suite's assertions read).
|
||||
function stubReplyLoop(api: CowritingApi): void {
|
||||
api.threadController.setTurnRunnerForTest(async () => {
|
||||
throw new Error("F2 suite: reply-loop turn stubbed off (not under test here)");
|
||||
});
|
||||
}
|
||||
|
||||
// Reaches the controller's internal docs map for reply/resolve handlers (which
|
||||
// expect a live vscode.CommentThread). Spec §6.8 "drive the ThreadController and
|
||||
// assert Comments-controller state" fallback.
|
||||
@@ -64,6 +78,7 @@ suite("F2 region-anchored threads (host E2E)", () => {
|
||||
test("create on selection persists a thread sidecar", async () => {
|
||||
const doc = await openSample();
|
||||
const api = await getApi();
|
||||
stubReplyLoop(api);
|
||||
const start = doc.getText().indexOf("quick brown fox");
|
||||
const editor = vscode.window.activeTextEditor!;
|
||||
editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length));
|
||||
@@ -86,6 +101,7 @@ suite("F2 region-anchored threads (host E2E)", () => {
|
||||
|
||||
test("reply appends and resolve flips status, both persisted", async () => {
|
||||
const api = await getApi();
|
||||
stubReplyLoop(api);
|
||||
const art0 = readSidecar();
|
||||
const threadId = art0.threads[0].id;
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import * as assert from "assert";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const DOC_REL = "docs/preview.md";
|
||||
const docUri = () => vscode.Uri.file(path.join(WS, DOC_REL)).toString();
|
||||
|
||||
async function openDoc(rel = DOC_REL): Promise<vscode.TextDocument> {
|
||||
const uri = vscode.Uri.file(path.join(WS, rel));
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
return doc;
|
||||
}
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController, "extension exports trackChangesPreviewController");
|
||||
return api;
|
||||
}
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
const kinds = (api: CowritingApi) =>
|
||||
(api.trackChangesPreviewController.getLastModel(docUri()) ?? []).map((o) => o.kind);
|
||||
|
||||
// Order-dependent (F2–F6 pattern): later tests consume earlier state.
|
||||
suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, no LLM)", () => {
|
||||
const TARGET = "A target sentence Claude will rewrite via the seam.";
|
||||
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
|
||||
|
||||
test("open on a markdown doc → panel open, opened baseline shows no changes (PUC-1)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), false, "no panel yet");
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), true, "panel open");
|
||||
const model = api.trackChangesPreviewController.getLastModel(docUri());
|
||||
assert.ok(model && model.length > 0, "a model was computed");
|
||||
assert.ok(
|
||||
model!.every((o) => o.kind === "unchanged"),
|
||||
"baseline == buffer at open → every block unchanged",
|
||||
);
|
||||
void doc;
|
||||
});
|
||||
|
||||
test("typing produces a changed/added block (PUC-2)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
const end = doc.positionAt(doc.getText().length);
|
||||
edit.insert(doc.uri, end, "\n\nA freshly typed paragraph.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||
await settle();
|
||||
assert.ok(
|
||||
kinds(api).some((k) => k === "added" || k === "changed"),
|
||||
"an added/changed block appears after typing",
|
||||
);
|
||||
});
|
||||
|
||||
test("accepting a proposal advances the baseline; the landed block goes unchanged (PUC-3, INV-18)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
const start = doc.getText().indexOf(TARGET);
|
||||
assert.ok(start >= 0, "fixture contains the target sentence");
|
||||
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||
uri: doc.uri.toString(),
|
||||
start,
|
||||
end: start + TARGET.length,
|
||||
newText: REPLACEMENT,
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f7",
|
||||
turnId: "turn-f7-1",
|
||||
});
|
||||
assert.ok(id, "propose returns an id");
|
||||
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
|
||||
await settle();
|
||||
// The baseline advanced to include REPLACEMENT, so the buffer == baseline for
|
||||
// that block → it is NOT marked. Confirm the replacement is not flagged as a change.
|
||||
const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
|
||||
const replacementMarked = model.some(
|
||||
(o) => o.kind !== "unchanged" && o.block.raw.includes("CLAUDE REWROTE"),
|
||||
);
|
||||
assert.ok(!replacementMarked, "the just-landed text renders unmarked (baseline advanced)");
|
||||
});
|
||||
|
||||
test("pin resets the baseline to now → preview shows no marks (PUC-4)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
|
||||
await settle();
|
||||
assert.ok(
|
||||
kinds(api).every((k) => k === "unchanged"),
|
||||
"after pin, every block is unchanged (baseline == buffer)",
|
||||
);
|
||||
void doc;
|
||||
});
|
||||
|
||||
test("a non-markdown doc → command warns and opens no panel (PUC-6, markdown-only)", async () => {
|
||||
const txt = await openDoc("docs/notes.txt");
|
||||
const api = await getApi();
|
||||
const key = txt.uri.toString();
|
||||
assert.notStrictEqual(txt.languageId, "markdown", "fixture is not markdown");
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for non-markdown");
|
||||
});
|
||||
|
||||
test("a changed flowchart augments the emitted mermaid source (#22, INV-29)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
// Show the preview and pin the baseline so the fixture's `a --> b` flowchart
|
||||
// is the "before"; then add an edge `a --> c` and confirm the intra-diagram diff.
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
|
||||
await settle();
|
||||
|
||||
const anchor = doc.getText().indexOf("a --> b");
|
||||
assert.ok(anchor >= 0, "fixture contains the flowchart edge");
|
||||
const insertAt = doc.positionAt(anchor + "a --> b".length);
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, insertAt, "\n a --> c");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "flowchart edit applied");
|
||||
await settle();
|
||||
|
||||
const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
|
||||
const mermaidOp = model.find((o) => o.kind === "changed" && o.block.type === "mermaid");
|
||||
assert.ok(mermaidOp, "the flowchart block is a changed mermaid op");
|
||||
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(docUri());
|
||||
assert.match(html, /classDef cwAdded/, "augmented with cwAdded classDef");
|
||||
assert.match(html, /class\s+c\s+cwAdded/, "node c colored added");
|
||||
assert.match(html, /cw-mermaid-legend/, "legend emitted");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { undoWorks, UNDO_SKIP_REASON } from "./undoCapable";
|
||||
import { renderHtmlFor } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
@@ -11,10 +12,13 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.attributionController && api?.trackChangesPreviewController, "exports attribution + preview");
|
||||
assert.ok(api?.attributionController && api?.proposalController, "exports attribution + proposal");
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Also enters coediting (INV-10/Task 2): the baseline is established only on
|
||||
* entry, not merely on open — needed for the preview's added/changed marks to
|
||||
* reflect a real diff rather than a vacuous baseline==current fallback. */
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
@@ -22,6 +26,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
@@ -78,8 +83,9 @@ suite("F10 #38 — undo does not mis-attribute restored text (host E2E, no LLM)"
|
||||
);
|
||||
|
||||
// And the on-state render must not color 'bravo' as human-authored.
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||
const bravoColoredHuman = /<span class="cw-by-human">[^<]*bravo/.test(html);
|
||||
assert.ok(!bravoColoredHuman, "restored 'bravo' is not colored cw-by-human in the preview");
|
||||
// Added blocks use cw-ins-human (not cw-by-human) since Task 3/44ef0a2.
|
||||
const html = renderHtmlFor(api, doc, key);
|
||||
const bravoColoredHuman = /cw-ins-human[^<]*bravo|<[^>]+class="[^"]*cw-ins-human[^"]*"[^>]*>[^<]*bravo/.test(html);
|
||||
assert.ok(!bravoColoredHuman, "restored 'bravo' is not colored cw-ins-human in the preview");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,44 @@ describe("extractReplacement", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runEditTurn macOS automation prompt (#59)", () => {
|
||||
// The claude-code provider spawns the bundled `claude` CLI, which on macOS
|
||||
// performs IDE auto-connect when launched inside VS Code — sending Apple Events
|
||||
// that raise the "control other applications" prompt. The extension only
|
||||
// consumes the text result and never needs that connection, so we disable it.
|
||||
it("spawns the agent with IDE auto-connect + auto-install disabled, preserving the rest of the environment", async () => {
|
||||
vi.resetModules();
|
||||
const cfgs: any[] = [];
|
||||
vi.doMock("@cline/sdk", () => ({
|
||||
Agent: class {
|
||||
constructor(cfg: any) {
|
||||
cfgs.push(cfg);
|
||||
}
|
||||
subscribe() {
|
||||
return () => {};
|
||||
}
|
||||
abort() {}
|
||||
async run() {
|
||||
return { status: "completed", outputText: "X", runId: "r" };
|
||||
}
|
||||
},
|
||||
}));
|
||||
process.env.__WGL_TEST_SENTINEL__ = "keep-me";
|
||||
await runEditTurn("do it", "old");
|
||||
const cfg = cfgs[0];
|
||||
// mK("0") === true in the claude binary → hard-disables IDE auto-connect,
|
||||
// so no osascript / Apple Event fires.
|
||||
expect(cfg.options?.env?.CLAUDE_CODE_AUTO_CONNECT_IDE).toBe("0");
|
||||
// Belt-and-suspenders: skip the IDE-extension auto-install (deep-link path).
|
||||
expect(cfg.options?.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL).toBe("1");
|
||||
// The full process environment must survive (login under HOME, proxy/CA vars).
|
||||
expect(cfg.options?.env?.__WGL_TEST_SENTINEL__).toBe("keep-me");
|
||||
delete process.env.__WGL_TEST_SENTINEL__;
|
||||
vi.doUnmock("@cline/sdk");
|
||||
vi.resetModules();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runEditTurn progress + cancel", () => {
|
||||
it("emits progress snapshots and returns the replacement unchanged (INV-44)", async () => {
|
||||
vi.resetModules();
|
||||
|
||||
+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 {
|
||||
SCHEMA_VERSION,
|
||||
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)", () => {
|
||||
it("round-trips an attribution record through serialize → parse", () => {
|
||||
const a = emptyArtifact("docs/x.md");
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cowritingMarkdownItPlugin } from "../src/previewAnnotations";
|
||||
|
||||
function render(src: string, inputs: any): string {
|
||||
const md = new MarkdownIt();
|
||||
cowritingMarkdownItPlugin(md, { inputsFor: () => inputs });
|
||||
return md.render(src);
|
||||
}
|
||||
|
||||
const base = { baselineReason: "entered", proposals: [], enabled: true };
|
||||
|
||||
describe("preview annotations (PUC-3/D21)", () => {
|
||||
it("renders clean when disabled", () => {
|
||||
const html = render("hello brave world\n", { ...base, enabled: false, baselineText: "hello world\n", spans: [] });
|
||||
expect(html).not.toContain("cw-");
|
||||
});
|
||||
it("colors machine spans blue and human insertions green vs baseline", () => {
|
||||
const html = render("hello brave new world\n", {
|
||||
...base,
|
||||
baselineText: "hello world\n",
|
||||
spans: [{ start: 6, end: 12, author: "claude" }, { start: 12, end: 16, author: "human" }],
|
||||
});
|
||||
expect(html).toContain('class="cw-ins-claude"');
|
||||
expect(html).toContain('class="cw-ins-human"');
|
||||
});
|
||||
it("strikes deletions vs baseline", () => {
|
||||
const html = render("hello world\n", { ...base, baselineText: "hello cruel world\n", spans: [] });
|
||||
expect(html).toContain("cw-del");
|
||||
expect(html).toContain("cruel");
|
||||
});
|
||||
it("pinned baseline renders clean even with spans (pin→clean, INV-33/#48)", () => {
|
||||
const html = render("hello brave world\n", {
|
||||
...base, baselineReason: "pinned", baselineText: "hello brave world\n",
|
||||
spans: [{ start: 6, end: 12, author: "claude" }],
|
||||
});
|
||||
expect(html).not.toContain("cw-ins");
|
||||
});
|
||||
it("survives intra-emphasis boundaries (#33 discipline)", () => {
|
||||
const html = render("a *bold claim* here\n", {
|
||||
...base, baselineText: "a here\n", spans: [{ start: 2, end: 14, author: "claude" }],
|
||||
});
|
||||
expect(html).toContain("cw-ins-claude");
|
||||
expect(html).not.toMatch(/[-]/); // no PUA sentinel leaks
|
||||
});
|
||||
// Task 8 (coverage moved from the deleted f10Review.test.ts PUC-3/9): a
|
||||
// pending F4 proposal is optimistically applied in the buffer (F12, INV-48),
|
||||
// so the preview renders its applied text ins-claude and reinserts the
|
||||
// pre-apply original as a deletion — proposals are agent-authored by
|
||||
// definition, so this is unconditional (no `spans` entry needed).
|
||||
it("a pending proposal renders its applied text cw-ins-claude and the original as a deletion", () => {
|
||||
const src = "hello CLAUDE world\n";
|
||||
const html = render(src, {
|
||||
...base,
|
||||
baselineText: "hello world\n",
|
||||
spans: [],
|
||||
proposals: [
|
||||
{ id: "p1", anchorStart: 6, anchorEnd: 12, replaced: "world", replacement: "CLAUDE" },
|
||||
],
|
||||
});
|
||||
expect(html).toContain('class="cw-ins-claude"');
|
||||
expect(html).toContain("CLAUDE");
|
||||
expect(html).toContain("cw-del");
|
||||
expect(html).toContain("world");
|
||||
});
|
||||
// Finding 4 (final whole-branch review, session native-surfaces-exec): the
|
||||
// applied-proposal insertion mark used to hardcode tag: "claude", so a
|
||||
// human-authored proposal (ProposalView.author = "human") still rendered
|
||||
// cw-ins-claude. It must follow `p.author` (defaulting to "claude" only
|
||||
// when the field is absent, per ProposalView's own documented default).
|
||||
it("a human-authored pending proposal renders its applied text cw-ins-human, not cw-ins-claude", () => {
|
||||
const src = "hello HUMAN world\n";
|
||||
const html = render(src, {
|
||||
...base,
|
||||
baselineText: "hello world\n",
|
||||
spans: [],
|
||||
proposals: [
|
||||
{ id: "p1", anchorStart: 6, anchorEnd: 11, replaced: "world", replacement: "HUMAN", author: "human" },
|
||||
],
|
||||
});
|
||||
expect(html).toContain('class="cw-ins-human"');
|
||||
expect(html).not.toContain('class="cw-ins-claude"');
|
||||
expect(html).toContain("HUMAN");
|
||||
});
|
||||
// Task 8 (INV-32/33 coverage moved from f10Review.test.ts): a block with no
|
||||
// spans and no baseline divergence carries no cw- marks at all, even when
|
||||
// annotations are enabled — "changed since baseline" is the only trigger.
|
||||
it("an unchanged block (baseline == src, no spans) renders with no cw- marks", () => {
|
||||
const html = render("hello unchanged world\n", {
|
||||
...base, baselineText: "hello unchanged world\n", spans: [],
|
||||
});
|
||||
expect(html).not.toContain("cw-");
|
||||
});
|
||||
});
|
||||
|
||||
// Task 7 §2.6 / cross-task review follow-up (plan-mandated, INV-29..31): the
|
||||
// built-in preview's mermaid fence rendering reuses the F7.1 (#22) pure
|
||||
// re-emit, same as the sunset webview did — a changed mermaid diagram gets
|
||||
// intra-diagram change styling, not just the plain current diagram. Ports the
|
||||
// substance of the E2E assertions Task 8 deleted with the webview
|
||||
// (`git show 2170a0d^:test/e2e/suite/trackChangesPreview.test.ts`, "a changed
|
||||
// flowchart augments the emitted mermaid source (#22, INV-29)") as pure unit
|
||||
// tests here instead — no DOM/webview needed to verify SOURCE augmentation.
|
||||
describe("intra-diagram mermaid diff in the built-in preview (plan T7 §2.6, INV-29)", () => {
|
||||
const flowchartBefore = "```mermaid\nflowchart LR\n a --> b\n```\n";
|
||||
const flowchartAfter = "```mermaid\nflowchart LR\n a --> b\n a --> c\n```\n";
|
||||
|
||||
it("a changed flowchart fence is re-emitted with cwAdded styling + a legend", () => {
|
||||
const html = render(flowchartAfter, { ...base, baselineText: flowchartBefore, spans: [] });
|
||||
expect(html).toMatch(/classDef cwAdded/);
|
||||
expect(html).toMatch(/class\s+c\s+cwAdded/);
|
||||
expect(html).toContain("cw-mermaid-legend");
|
||||
expect(html).toContain('class="mermaid"');
|
||||
});
|
||||
|
||||
it("an unchanged mermaid fence passes through byte-identical (no diff markup)", () => {
|
||||
const html = render(flowchartBefore, { ...base, baselineText: flowchartBefore, spans: [] });
|
||||
expect(html).not.toContain("cwAdded");
|
||||
expect(html).not.toContain("cw-mermaid-legend");
|
||||
expect(html).toContain("flowchart LR");
|
||||
expect(html).toContain("a --> b");
|
||||
});
|
||||
|
||||
it("annotations disabled: a changed mermaid fence renders the plain current diagram", () => {
|
||||
const html = render(flowchartAfter, { ...base, enabled: false, baselineText: flowchartBefore, spans: [] });
|
||||
expect(html).not.toContain("cwAdded");
|
||||
expect(html).not.toContain("cw-mermaid-legend");
|
||||
expect(html).toContain("a --> c");
|
||||
});
|
||||
|
||||
it("pinned baseline with zero landed diff: mermaid fence renders the plain current diagram (INV-33/#48 parity)", () => {
|
||||
const html = render(flowchartAfter, {
|
||||
...base, baselineReason: "pinned", baselineText: flowchartAfter, spans: [],
|
||||
});
|
||||
expect(html).not.toContain("cwAdded");
|
||||
expect(html).not.toContain("cw-mermaid-legend");
|
||||
});
|
||||
|
||||
it("no baseline (no coediting context established): mermaid fence renders the plain current diagram", () => {
|
||||
const html = render(flowchartAfter, { ...base, baselineText: undefined, spans: [] });
|
||||
expect(html).not.toContain("cwAdded");
|
||||
expect(html).not.toContain("cw-mermaid-legend");
|
||||
});
|
||||
|
||||
it("a non-mermaid changed fence is untouched by the mermaid queue (fence-kind gate)", () => {
|
||||
const before = "```js\nconst a = 1;\n```\n";
|
||||
const after = "```js\nconst a = 2;\n```\n";
|
||||
const html = render(after, { ...base, baselineText: before, spans: [] });
|
||||
expect(html).not.toContain("cwAdded");
|
||||
expect(html).not.toContain("cw-mermaid-legend");
|
||||
expect(html).toContain('class="language-js"');
|
||||
});
|
||||
|
||||
// Code-review hardening (705de31 follow-up): the queue used to be consumed
|
||||
// POSITIONALLY per mermaid fence encountered, so a fence the queue doesn't
|
||||
// know about (e.g. a pending proposal's optimistic apply, F12/INV-48,
|
||||
// inserting a whole new mermaid fence ABOVE a changed one) shifted every
|
||||
// later fence's override by one, substituting the wrong diagram's augmented
|
||||
// source into the wrong fence instead of the documented verbatim fallback.
|
||||
// The fix matches queue entries to fences by BODY: a fence with no matching
|
||||
// entry renders verbatim without consuming anything, so a later matching
|
||||
// fence still finds its own entry.
|
||||
it("misalignment: an extra proposal-inserted mermaid fence above a changed one renders verbatim, and the changed fence still gets its own augmentation", () => {
|
||||
const extraFence = "```mermaid\nsequenceDiagram\n A->>B: hi\n```\n";
|
||||
const src = extraFence + "\n" + flowchartAfter;
|
||||
const html = render(src, {
|
||||
...base,
|
||||
baselineText: flowchartBefore,
|
||||
spans: [],
|
||||
proposals: [{ id: "p1", anchorStart: 0, anchorEnd: extraFence.length, replaced: "", replacement: extraFence }],
|
||||
});
|
||||
const fences = html.split('<pre class="mermaid"').slice(1); // one chunk per fence, document order
|
||||
expect(fences).toHaveLength(2);
|
||||
// the extra fence has no matching queue entry: verbatim, no wrong-diagram substitution
|
||||
expect(fences[0]).toContain("sequenceDiagram");
|
||||
expect(fences[0]).not.toContain("cwAdded");
|
||||
// the changed fence still finds its own augmentation despite the extra fence ahead of it in document order
|
||||
expect(fences[1]).toContain("classDef cwAdded");
|
||||
expect(fences[1]).toMatch(/class\s+c\s+cwAdded/);
|
||||
expect((html.match(/cw-mermaid-legend/g) ?? []).length).toBe(1); // legend only on the augmented fence
|
||||
});
|
||||
|
||||
// Body-match must not collapse two changed fences that happen to render the
|
||||
// SAME current diagram into a single queue entry — each occurrence gets its
|
||||
// own independent augmentation (consume-first-match, not a body->entry map).
|
||||
it("two identical-body changed mermaid fences each get their own augmentation", () => {
|
||||
const baselineText = flowchartBefore + "\n" + flowchartBefore;
|
||||
const src = flowchartAfter + "\n" + flowchartAfter;
|
||||
const html = render(src, { ...base, baselineText, spans: [] });
|
||||
const fences = html.split('<pre class="mermaid"').slice(1);
|
||||
expect(fences).toHaveLength(2);
|
||||
for (const f of fences) {
|
||||
expect(f).toContain("classDef cwAdded");
|
||||
expect(f).toMatch(/class\s+c\s+cwAdded/);
|
||||
}
|
||||
expect((html.match(/cw-mermaid-legend/g) ?? []).length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type Proposal,
|
||||
} from "../src/model";
|
||||
import { CoauthorStore } from "../src/store";
|
||||
import { addProposal, proposalBody, removeProposal } from "../src/proposalModel";
|
||||
import { addProposal, proposalBody, removeProposal, setProposalApplied } from "../src/proposalModel";
|
||||
|
||||
const agent = {
|
||||
kind: "agent" as const,
|
||||
@@ -104,4 +104,19 @@ describe("proposalModel helpers (spec §6.4)", () => {
|
||||
expect(body).toContain("- old line two");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
+230
-28
@@ -1,5 +1,7 @@
|
||||
import { describe, it, test, expect } from "vitest";
|
||||
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, countLineHunks, type AuthorSpan } from "../src/trackChangesModel";
|
||||
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
||||
|
||||
describe("splitBlocks", () => {
|
||||
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
||||
@@ -170,12 +172,11 @@ describe("colorByAuthor", () => {
|
||||
});
|
||||
|
||||
// #33: author-coloring must be sentinel-safe around markdown emphasis. These
|
||||
// exercise the REAL markdown-it renderer (via renderReview on an unchanged doc),
|
||||
// since the failure is in markdown-it's inline parsing / element nesting.
|
||||
// exercise colorByAuthor directly with the real markdown-it renderer (unchanged
|
||||
// blocks now render plain in renderReview, so sentinel testing requires a direct call).
|
||||
// CASE1 — a span boundary strictly inside a delimiter run must not split it.
|
||||
test("#33 CASE1: a span boundary inside ** does not break emphasis parsing", () => {
|
||||
const doc = "a**b**c";
|
||||
const html = renderReview(doc, doc, [{ start: 0, end: 2, author: "human" }], []);
|
||||
const html = colorByAuthor("a**b**c", 0, [{ start: 0, end: 2, author: "human" }], src => md.render(src));
|
||||
expect(html).toContain("<strong>b</strong>"); // emphasis still renders
|
||||
expect(html).not.toContain("**"); // no raw delimiters left
|
||||
expect(html).not.toContain("<em></em>"); // no stray empty emphasis (the parse-break symptom)
|
||||
@@ -184,16 +185,14 @@ describe("colorByAuthor", () => {
|
||||
// CASE3 — a span boundary inside an emphasis run must color the text without
|
||||
// misnesting span/element (the span is split at the element boundary).
|
||||
test("#33 CASE3: a span boundary inside **bold** colors the text without misnesting", () => {
|
||||
const doc = "**bold**";
|
||||
const html = renderReview(doc, doc, [{ start: 0, end: 4, author: "human" }], []); // covers "**bo"
|
||||
const html = colorByAuthor("**bold**", 0, [{ start: 0, end: 4, author: "human" }], src => md.render(src)); // covers "**bo"
|
||||
expect(html).toContain("<strong>");
|
||||
expect(html).toContain('<span class="cw-by-human">bo</span>ld</strong>'); // span INSIDE strong, closed before "ld"
|
||||
expect(html).not.toContain('cw-by-human"><strong>'); // NOT the old misnest (span wrapping the <strong> open)
|
||||
});
|
||||
// CASE2 — a span covering a whole emphasis run stays correct (regression).
|
||||
test("#33 CASE2: a span over the whole **bold** colors it correctly", () => {
|
||||
const doc = "**bold**";
|
||||
const html = renderReview(doc, doc, [{ start: 0, end: 8, author: "human" }], []);
|
||||
const html = colorByAuthor("**bold**", 0, [{ start: 0, end: 8, author: "human" }], src => md.render(src));
|
||||
expect(html).toContain("<strong>");
|
||||
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
|
||||
expect(html).toContain("bold");
|
||||
@@ -209,7 +208,98 @@ describe("colorByAuthor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
import { renderPlain } from "../src/trackChangesModel";
|
||||
describe("authorAt", () => {
|
||||
const spans: AuthorSpan[] = [
|
||||
{ start: 0, end: 5, author: "human" },
|
||||
{ start: 5, end: 10, author: "claude" },
|
||||
];
|
||||
test("returns the covering span's author (half-open)", () => {
|
||||
expect(authorAt(0, spans)).toBe("human");
|
||||
expect(authorAt(4, spans)).toBe("human");
|
||||
expect(authorAt(5, spans)).toBe("claude");
|
||||
expect(authorAt(9, spans)).toBe("claude");
|
||||
});
|
||||
test("returns null outside any span", () => {
|
||||
expect(authorAt(10, spans)).toBeNull();
|
||||
expect(authorAt(-1, spans)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wordDiffByAuthor", () => {
|
||||
test("an inserted run is colored by the author covering its landed offset", () => {
|
||||
// before "hello " ; after "hello brave " ; "brave " inserted at landed offset 6..12
|
||||
const spans: AuthorSpan[] = [{ start: 6, end: 12, author: "claude" }];
|
||||
const html = wordDiffByAuthor("hello ", "hello brave ", 0, spans);
|
||||
expect(html).toContain('<ins class="cw-ins-claude">brave </ins>');
|
||||
expect(html).toContain("hello ");
|
||||
});
|
||||
test("a paired deletion inherits the paired insertion's author (adjacency)", () => {
|
||||
// "light" -> "dark", both in one cluster; "dark" is claude-inserted
|
||||
const spans: AuthorSpan[] = [{ start: 0, end: 4, author: "claude" }];
|
||||
const html = wordDiffByAuthor("light", "dark", 0, spans);
|
||||
expect(html).toContain('<del class="cw-del-claude">light</del>');
|
||||
expect(html).toContain('<ins class="cw-ins-claude">dark</ins>');
|
||||
});
|
||||
test("a standalone deletion (no paired insertion) is neutral", () => {
|
||||
const html = wordDiffByAuthor("keep this word", "keep word", 0, []);
|
||||
expect(html).toContain('<del class="cw-del-none">this </del>');
|
||||
expect(html).not.toContain("cw-del-human");
|
||||
expect(html).not.toContain("cw-del-claude");
|
||||
});
|
||||
test("emits SIBLING ins/del elements, never nested — CSS overlap rule in preview.css is a defensive forward guarantee", () => {
|
||||
// The engine assigns the cluster's insertion author to all deletions in the same
|
||||
// cluster (adjacency heuristic), so <del> and <ins> always carry the SAME author
|
||||
// as siblings. Cross-author nesting (<del class="cw-del-X"><ins class="cw-ins-Y">)
|
||||
// is never produced today. The CSS rule `.cw-del-claude .cw-ins-human { text-decoration: inherit }`
|
||||
// guards against a future regression where such nesting appears.
|
||||
const spans: AuthorSpan[] = [{ start: 0, end: 100, author: "human" }];
|
||||
const html = wordDiffByAuthor("old text", "new text", 0, spans);
|
||||
expect(html).toContain('<del class="cw-del-human">');
|
||||
expect(html).toContain('<ins class="cw-ins-human">');
|
||||
// Structural check: no <del> immediately wraps an <ins> (sibling, not nested).
|
||||
// /<del[^>]*>[^<]*<ins/ would match a nested case but not a sibling case
|
||||
// because [^<]* stops before the closing </del> tag.
|
||||
expect(html).not.toMatch(/<del[^>]*>[^<]*<ins/);
|
||||
});
|
||||
});
|
||||
|
||||
import { renderPlain, wordEditHunks, decorationPlan } from "../src/trackChangesModel";
|
||||
|
||||
test("committed change: wordEditHunks(current, baseline) → start/end index current; replacement is baseline text", () => {
|
||||
const baseline = "the light mode";
|
||||
const current = "the dark mode";
|
||||
const [h] = wordEditHunks(current, baseline);
|
||||
expect(current.slice(h.start, h.end)).toContain("dark"); // applied (current) side
|
||||
expect(h.replacement).toContain("light"); // baseline (deleted) side
|
||||
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
||||
expect(plan.insertions.length).toBeGreaterThan(0);
|
||||
expect(plan.deletions.some((d) => d.text.includes("light"))).toBe(true);
|
||||
});
|
||||
|
||||
test("committed change with NO shared prefix: current offsets + deleted text both correct", () => {
|
||||
const baseline = "alpha tail";
|
||||
const current = "beta tail";
|
||||
const [h] = wordEditHunks(current, baseline);
|
||||
expect(h.start).toBe(0);
|
||||
expect(current.slice(h.start, h.end)).toContain("beta"); // applied
|
||||
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
||||
expect(plan.deletions.some((d) => d.text.includes("alpha"))).toBe(true); // deletion not lost
|
||||
});
|
||||
|
||||
test("standalone committed deletion → plan has no insertions (controller routes del to neutral)", () => {
|
||||
// current = "keep word" (baseline had "this " between "keep " and "word" — standalone deletion)
|
||||
const [h] = wordEditHunks("keep word", "keep this word");
|
||||
const plan = decorationPlan(h.start, h.replacement, "keep word".slice(h.start, h.end));
|
||||
expect(plan.insertions.length).toBe(0);
|
||||
expect(plan.deletions.some((d) => d.text.includes("this"))).toBe(true);
|
||||
});
|
||||
|
||||
test("paired committed replace → plan has insertions (deletion inherits author, not neutral)", () => {
|
||||
// current = "the dark mode", baseline = "the light mode" — replacement, so paired
|
||||
const [h] = wordEditHunks("the dark mode", "the light mode");
|
||||
const plan = decorationPlan(h.start, h.replacement, "the dark mode".slice(h.start, h.end));
|
||||
expect(plan.insertions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe("renderPlain", () => {
|
||||
test("renderPlain renders current buffer as plain markdown (no marks)", () => {
|
||||
@@ -223,15 +313,17 @@ describe("renderPlain", () => {
|
||||
import { renderReview, type ProposalView } from "../src/trackChangesModel";
|
||||
|
||||
describe("renderReview", () => {
|
||||
test("renderReview: human addition since baseline renders green ins / cw-by-human", () => {
|
||||
const html = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
|
||||
expect(html).toMatch(/<ins>[^<]*world[^<]*<\/ins>|cw-by-human/);
|
||||
test("renderReview: human addition since baseline renders author-colored ins", () => {
|
||||
// " world" is one diff token starting at offset 5 (the space), so the author span
|
||||
// must start at 5 or earlier to cover the token's start offset.
|
||||
const html = renderReview("hello", "hello world", [{ start: 5, end: 11, author: "human" }], []);
|
||||
expect(html).toContain("cw-ins-human");
|
||||
});
|
||||
test("renderReview: deletion since baseline renders struck del/cw-del", () => {
|
||||
test("renderReview: a standalone deletion since baseline renders neutral struck del", () => {
|
||||
const html = renderReview("hello world", "hello", [], []);
|
||||
expect(html).toMatch(/<del>|cw-del/);
|
||||
expect(html).toMatch(/cw-del-none|cw-removed/);
|
||||
});
|
||||
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 html = renderReview("hello", "hello", [], proposals);
|
||||
expect(html).toContain('class="cw-proposal"');
|
||||
@@ -261,13 +353,15 @@ describe("renderReview", () => {
|
||||
test("renderReview: author-colors the correct block when two paragraphs are identical", () => {
|
||||
// Two identical paragraphs; baseline has only the first, so the SECOND is an
|
||||
// added block authored by human. Its span must color THAT block, not the first.
|
||||
// (Unchanged first block renders plain; added second block uses wordDiffByAuthor.)
|
||||
const baseline = "Hello world";
|
||||
const current = "Hello world\n\nHello world";
|
||||
// second "Hello world" starts at offset 13; "world" at 19..24
|
||||
const spans = [{ start: 19, end: 24, author: "human" as const }];
|
||||
// second "Hello world" starts at offset 13 (past the "\n\n"); span covers the block.
|
||||
// wordDiffByAuthor emits the whole block as one added token at offset 13.
|
||||
const spans = [{ start: 13, end: 24, author: "human" as const }];
|
||||
const html = renderReview(baseline, current, spans, []);
|
||||
// exactly one cw-by-human span (the added second block's "world"), not zero, not on the first.
|
||||
const count = (html.match(/cw-by-human/g) ?? []).length;
|
||||
// exactly one cw-ins-human (the added second block), not zero, not on the first.
|
||||
const count = (html.match(/cw-ins-human/g) ?? []).length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
@@ -346,22 +440,24 @@ describe("renderReview", () => {
|
||||
expect(html).toContain('data-proposal-id="p1"'); // proposal still shows (it is an action)
|
||||
expect(html).toContain("Person");
|
||||
});
|
||||
test("renderReview: zero diff WITHOUT a pin (e.g. machine-landing) keeps authorship coloring (INV-33)", () => {
|
||||
// accepting a Claude edit advances the baseline (zero diff) but is NOT a pin —
|
||||
// the landed author coloring must remain.
|
||||
test("renderReview: zero diff (baseline == current) renders unchanged blocks plain (Task 2 behavior)", () => {
|
||||
// Task 2: unchanged blocks always render plain — color means "changed since baseline".
|
||||
// When baseline == current (e.g. right after a fresh snapshot) there's no diff → no coloring.
|
||||
const doc = "Human wrote this.\n\nClaude wrote that.";
|
||||
const spans: AuthorSpan[] = [{ start: 19, end: doc.length, author: "claude" }];
|
||||
const html = renderReview(doc, doc, spans, []); // no pinned flag
|
||||
expect(html).toContain("cw-by-claude");
|
||||
expect(html).not.toContain("cw-by-claude");
|
||||
expect(html).toContain("Claude wrote that."); // text still present
|
||||
});
|
||||
test("renderReview: with REAL changes since a pin, author coloring returns", () => {
|
||||
test("renderReview: with REAL changes since a pin, author coloring returns on added blocks", () => {
|
||||
// a genuine added block is still author-colored even when pinned (only the
|
||||
// zero-diff-after-pin state is clean).
|
||||
// zero-diff-after-pin state is clean). Task 2: coloring is via cw-ins-*.
|
||||
const baseline = "Hello world";
|
||||
const current = "Hello world\n\nHello world";
|
||||
const spans: AuthorSpan[] = [{ start: 19, end: 24, author: "human" }];
|
||||
// span covers the added block from its start (offset 13) so the whole token is colored.
|
||||
const spans: AuthorSpan[] = [{ start: 13, end: 24, author: "human" }];
|
||||
const html = renderReview(baseline, current, spans, [], { pinned: true });
|
||||
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
|
||||
expect((html.match(/cw-ins-human/g) ?? []).length).toBe(1);
|
||||
});
|
||||
|
||||
test("renderReview is deterministic with mixed anchored/unanchored proposals", () => {
|
||||
@@ -375,6 +471,96 @@ describe("renderReview", () => {
|
||||
const b = renderReview(doc, doc, [], proposals);
|
||||
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 an ADDED block AFTER a length-changing pending proposal (INV-49/50)", () => {
|
||||
// block 1 has a pending proposal (MUCH LONGER than original); block 2 is a NEW
|
||||
// paragraph with a Claude authorship span (in currentText coords). The toLanded
|
||||
// mapping must shift the span's coords so the added block is correctly colored.
|
||||
const baseline = "old.";
|
||||
const current = "LONGER REPLACEMENT.\n\nAdded para.";
|
||||
const proposals: ProposalView[] = [{
|
||||
id: "pr_1",
|
||||
anchorStart: 0,
|
||||
anchorEnd: "LONGER REPLACEMENT.".length,
|
||||
replaced: "old.",
|
||||
replacement: "LONGER REPLACEMENT.",
|
||||
original: "old.",
|
||||
}];
|
||||
// "Added" in currentText starts at: "LONGER REPLACEMENT.\n\n".length = 21
|
||||
const aStart = current.indexOf("Added");
|
||||
const authorSpans = [{ start: aStart, end: aStart + "Added".length, author: "claude" as const }];
|
||||
const html = renderReview(baseline, current, authorSpans, proposals);
|
||||
// "Added" in the landedText block should be claude-colored despite the coordinate shift.
|
||||
expect(html).toContain("cw-ins-claude");
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
test("renderReview: a changed prose block colors ins/del by author (claude replace)", () => {
|
||||
// baseline "light" -> current "dark"; "dark" attributed to claude at 0..4
|
||||
const html = renderReview("light", "dark", [{ start: 0, end: 4, author: "claude" }], []);
|
||||
expect(html).toContain('cw-ins-claude');
|
||||
expect(html).toContain('cw-del-claude'); // adjacency: struck "light" inherits claude
|
||||
});
|
||||
|
||||
test("renderReview: an unchanged block renders plain (no author coloring)", () => {
|
||||
const doc = "Alpha stays.";
|
||||
const html = renderReview(doc, doc, [{ start: 0, end: 12, author: "human" }], []);
|
||||
expect(html).not.toContain("cw-by-");
|
||||
expect(html).not.toContain("cw-ins-");
|
||||
});
|
||||
|
||||
test("renderReview: an added block is author-colored as an insertion", () => {
|
||||
// baseline empty-ish -> add a new paragraph authored by human
|
||||
const html = renderReview("Keep.", "Keep.\n\nFresh line.", [{ start: 6, end: 17, author: "human" }], []);
|
||||
expect(html).toContain("cw-ins-human");
|
||||
});
|
||||
|
||||
test("renderReview: an added heading block renders as a heading AND is author-colored as an insertion", () => {
|
||||
// "Intro.\n\n" = 8 chars; "## New Section" = 14 chars → offset 8..22 in currentText.
|
||||
// Before fix: wordDiffByAuthor wrapped raw in <ins> BEFORE markdown-it parsed it,
|
||||
// so "## New Section" rendered as literal text (structural bug). After fix:
|
||||
// colorByAuthor uses the sentinel technique — markdown-it parses the block first,
|
||||
// so ## becomes a real <h2>, THEN sentinels are replaced with cw-ins-* spans.
|
||||
const html = renderReview("Intro.", "Intro.\n\n## New Section", [{ start: 8, end: 22, author: "human" }], []);
|
||||
expect(html).toMatch(/<h[12][^>]*>/); // real heading element, not literal "## "
|
||||
expect(html).toContain("cw-ins-human"); // author-colored insertion
|
||||
expect(html).not.toContain(">>"); // sanity: markers not escaped as text
|
||||
});
|
||||
|
||||
test("renderReview: a proposal block colors its del/ins by author (claude default)", () => {
|
||||
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
|
||||
const html = renderReview("hello", "hello", [], proposals);
|
||||
expect(html).toContain('cw-del-claude');
|
||||
expect(html).toContain('cw-ins-claude');
|
||||
});
|
||||
});
|
||||
|
||||
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
|
||||
@@ -639,3 +825,19 @@ describe("F11 data-src emission (INV-36)", () => {
|
||||
expect(renderReview(doc, doc, [], [])).not.toContain('href="https://example.com/spec"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("countLineHunks", () => {
|
||||
it("0 for identical text", () => {
|
||||
expect(countLineHunks("a\nb\n", "a\nb\n")).toBe(0);
|
||||
});
|
||||
it("1 for one contiguous change", () => {
|
||||
expect(countLineHunks("a\nb\nc\n", "a\nX\nc\n")).toBe(1);
|
||||
});
|
||||
it("2 for two separated changes", () => {
|
||||
expect(countLineHunks("a\nb\nc\nd\ne\n", "a\nX\nc\nd\nY\n")).toBe(2);
|
||||
});
|
||||
it("counts pure insertions and deletions", () => {
|
||||
expect(countLineHunks("a\n", "a\nb\n")).toBe(1);
|
||||
expect(countLineHunks("a\nb\n", "a\n")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isAuthorable, isUnderRoot, selectionRejection } from "../src/workspacePath";
|
||||
import { isAuthorable, isUnderRoot, routeEdit, selectionRejection } from "../src/workspacePath";
|
||||
|
||||
describe("isUnderRoot", () => {
|
||||
const root = "/a/vscode-cowriting-plugin/sandbox";
|
||||
@@ -58,3 +58,27 @@ describe("selectionRejection — F8 widened (accepts out-of-folder + untitled)",
|
||||
expect(msg).not.toMatch(/select some text/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeEdit (single Ask-Claude-to-Edit gesture)", () => {
|
||||
const focused = { hasUri: false, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false };
|
||||
|
||||
it("non-empty selection in the focused editor → selection", () => {
|
||||
expect(routeEdit(focused)).toBe("selection");
|
||||
});
|
||||
it("empty selection in the focused editor → document", () => {
|
||||
expect(routeEdit({ ...focused, selectionEmpty: true })).toBe("document");
|
||||
});
|
||||
it("no active editor → document", () => {
|
||||
expect(routeEdit({ ...focused, hasActiveEditor: false, selectionEmpty: true })).toBe("document");
|
||||
});
|
||||
it("tab right-click on a doc that ISN'T the focused editor → document (no selection to act on)", () => {
|
||||
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false })).toBe(
|
||||
"document",
|
||||
);
|
||||
});
|
||||
it("tab right-click on the focused editor with a selection → selection", () => {
|
||||
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: true, hasActiveEditor: true, selectionEmpty: false })).toBe(
|
||||
"selection",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user