Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3ea6c65ca | |||
| 21bbf6b114 | |||
| 0743cf9a8a | |||
| 946625e899 | |||
| 20e13bba4d | |||
| d2405a2ca8 | |||
| 5d9f7ddaaf | |||
| 98b33ff53b | |||
| c5e464fbeb | |||
| 16cca30d39 | |||
| 9b6a15a43c | |||
| edba577586 | |||
| ceca17aa40 | |||
| 528c76d23b | |||
| b9517e0f68 | |||
| 5a02e793dd | |||
| faf0810a6c | |||
| d597c1c362 | |||
| 790d88c827 | |||
| 37953cfcad | |||
| 24e329e25d | |||
| 96a689aedf | |||
| 911ed21671 | |||
| 2ca0fc8c51 | |||
| 3d9270ecc4 | |||
| e53f0c30ad | |||
| ad6cbe10c7 | |||
| 64c7d7bad2 | |||
| ab00cbefcc | |||
| c67749a53c | |||
| ba7623f813 | |||
| 9e9fcb8057 | |||
| 1381eab11e | |||
| 54846da1ea | |||
| fdd743490d | |||
| 4584e06679 | |||
| ef07acfdc1 | |||
| 83c4a80d8b | |||
| c94b9ccfe7 | |||
| 6fd7555183 | |||
| f7788c5585 | |||
| b69d0f7d15 | |||
| dc38b55c57 | |||
| 2f6008ba2b | |||
| 6e944ab4cc | |||
| 2175efb5e5 | |||
| 5dc7d19419 | |||
| e804c46ba0 | |||
| 9c3770d26a | |||
| 60a396c09e | |||
| 9ec0862353 | |||
| d5ef6dc90f | |||
| 261f0de240 | |||
| 46f74c8247 | |||
| cc3d778da9 | |||
| 981640f3d5 | |||
| 2978663c14 | |||
| 5eb13deca3 | |||
| 91c55870f2 | |||
| eea4145904 | |||
| 695b51f903 | |||
| de53305a08 | |||
| 21df67022f | |||
| 62a2229c25 | |||
| 47cc733026 | |||
| 1564ef562b | |||
| 03b61ed43e | |||
| 0d1a5635cb | |||
| 1ef9451e89 | |||
| 8b9e61a1da | |||
| 5966907089 | |||
| 2e3179eaec | |||
| b2de25ff99 | |||
| f2b6f369f5 | |||
| ac191556a6 | |||
| 10ad1a6992 | |||
| 440d8ff4c9 | |||
| 3c8b3597dd | |||
| 469c8c11fc | |||
| 21e889b0a3 | |||
| 7e15c887f3 | |||
| 0ada9ed9da | |||
| d70fa91e59 | |||
| 1a52ac6cb4 | |||
| 3520397e41 |
@@ -14,8 +14,9 @@ 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), and F10 interactive review — **write left / review
|
||||
right** (Feature #29).
|
||||
toggle (Feature #17), F10 interactive review — **write left / review
|
||||
right** (Feature #29), and F11 — the **preview toolbar as the primary
|
||||
interaction surface** (Feature #43).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -244,7 +245,10 @@ A clean, **zero-annotation editor** on the left; the rendered preview on the
|
||||
right as the **single interactive review surface**. The editor carries no
|
||||
attribution tint, no in-editor proposal threads, and no diff — all review lives
|
||||
in the preview, toggled by the **Annotations** switch in its header (on by
|
||||
default). `Ctrl+Alt+R` opens **"Open Review Preview"**.
|
||||
default). Open it via `Ctrl+Alt+R`, the editor title-bar button, or
|
||||
**right-click a markdown file in the Explorer / its editor tab →
|
||||
"Open Cowriting Review Panel"** (#41) — the right-click acts on the clicked
|
||||
document, opening it first if needed.
|
||||
|
||||
In the on-state the preview shows **green = human / blue = Claude /
|
||||
strikethrough = deleted**, and surfaces each of Claude's pending F4 proposals as
|
||||
@@ -262,6 +266,33 @@ 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)
|
||||
|
||||
The review preview's **header toolbar** becomes the cockpit for the inner loop.
|
||||
Beside the existing **Annotations** switch it gains two controls:
|
||||
|
||||
- **⌖ Pin baseline** — pins the previewed document's review baseline to now and
|
||||
clears the change-marks (homes the previously-orphaned `pinDiffBaseline`
|
||||
command, which is reachable from the palette again too).
|
||||
- **✦ Ask Claude…** — one **adaptive** button. Its label flips on the preview's
|
||||
selection: **Edit Selection** when text is selected in the rendered preview,
|
||||
**Edit Document** otherwise. Clicking it opens a host input box for the
|
||||
instruction (the LLM turn and prompt stay host-side — the sealed webview gains
|
||||
no LLM/credential surface), then surfaces the result as F4 proposals — **one**
|
||||
for a selection (mapped back to its source block-union), or **one per changed
|
||||
hunk** for a whole-document rewrite (`diffToHunks`), each independently ✓/✗-able.
|
||||
|
||||
The pure render layer emits `data-src-start`/`data-src-end` on every block in
|
||||
**both** modes (INV-36); the webview's only mapping duty is walking a selection
|
||||
to its nearest `data-src` ancestor. Right-clicking a markdown tab → **Open Review
|
||||
Preview** is the minimal gateway making the surface reachable end to end (#41/#42
|
||||
expand it). Edit controls are disabled on a non-authorable doc (reading stays
|
||||
allowed). No new model, no new persistence — pin via the F6 store, edits via the
|
||||
F4 propose/accept seam with F3 attribution (INV-35..37).
|
||||
|
||||
Design: [`docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md`](docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md).
|
||||
Live smoke: [`docs/MANUAL-SMOKE-F11.md`](docs/MANUAL-SMOKE-F11.md).
|
||||
|
||||
## Develop
|
||||
|
||||
- `npm run watch` — rebuild on change.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Manual smoke — F11 preview toolbar as the primary interaction surface (#43)
|
||||
|
||||
F11 makes the rendered preview's **header toolbar** the primary interaction
|
||||
surface: beside the existing **Annotations** switch it gains a **Pin baseline**
|
||||
button and a **single adaptive Ask-Claude button** (Edit Selection when text is
|
||||
selected in the preview, Edit Document otherwise). The webview's *visual*
|
||||
behavior — the buttons, the live label flip, and the selection→source mapping —
|
||||
is verified **here**, not in the automated host E2E (the webview is a sealed
|
||||
sandbox; the host's message→seam wiring is covered by the F11 E2E suite). Run
|
||||
once per change that touches F11. Live turns hit the SDK; use a trivial
|
||||
instruction to keep them quick.
|
||||
|
||||
## Setup
|
||||
|
||||
1. `npm run build`
|
||||
2. Launch the Extension Development Host (F5 in VS Code, or the Run panel) with
|
||||
`sandbox/` open.
|
||||
3. Open a markdown document with a few paragraphs (e.g. copy
|
||||
`test/e2e/fixtures/workspace/docs/preview.md`).
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Right-click gateway (PUC-6).** Right-click the markdown editor **tab** (or
|
||||
use the editor title bar) → **"Cowriting: Open Review Preview"**. The preview
|
||||
opens beside the editor. (The palette entry and `Ctrl+Alt+R` still work too.)
|
||||
2. **Three header controls (PUC-1).** The header row shows, left to right: the
|
||||
**☑ Annotations** checkbox (on), a **⌖ Pin baseline** button, and a **✦ Ask
|
||||
Claude to Edit Document** button. They restyle with the theme.
|
||||
3. **Adaptive label — selection (PUC-2).** Select a paragraph **in the preview**
|
||||
(drag across rendered text). The Ask-Claude button label flips live to **"✦
|
||||
Ask Claude to Edit Selection"**. Click elsewhere to clear the selection → it
|
||||
flips back to **"✦ Ask Claude to Edit Document"**.
|
||||
4. **Edit Selection (PUC-3).** With a paragraph selected in the preview, click
|
||||
**Ask Claude to Edit Selection** → a host input box appears → type an
|
||||
instruction (e.g. "tighten this") → submit. A **blue `cw-proposal` block**
|
||||
appears **inline at that paragraph** with **✓ / ✗** (the selection mapped back
|
||||
to its source block; the edit ran host-side). The editor does **not** change
|
||||
(INV-10). Accept with **✓**: the replacement lands; the block clears.
|
||||
5. **Edit Document (PUC-4).** Clear the selection (button reads "Edit Document").
|
||||
Click it → instruct (e.g. "fix any typos and tighten") → submit. Claude's
|
||||
whole-document rewrite surfaces as **one or more** independent blue proposal
|
||||
blocks (one per changed hunk), each independently **✓ / ✗**-able. Accept some,
|
||||
reject others — each behaves as an ordinary F10 proposal.
|
||||
6. **Pin baseline (PUC-5).** Make (or accept) a few changes so the preview shows
|
||||
change-marks and a non-zero summary. Click **⌖ Pin baseline**. Expect: the
|
||||
change-marks **clear**, the summary resets to `+0 −0`, and the header epoch
|
||||
reads **`pinned <time>`**. "What changed" now counts from this moment.
|
||||
7. **Off-state still maps (INV-36).** Toggle **Annotations** off (clean preview).
|
||||
Selecting a paragraph still flips the button to **Edit Selection** and an edit
|
||||
still works — the off/clean preview is also a selection→source surface.
|
||||
8. **Non-authorable doc (PUC-1/7).** Open a markdown doc from a read-only/virtual
|
||||
source (e.g. a Git diff view, or an Output channel rendered as markdown) and
|
||||
open the preview. Expect: **Pin baseline** and **Ask-Claude** render
|
||||
**disabled**; the **Annotations** toggle stays active (reading is allowed).
|
||||
9. **Theme.** Toggle light / dark / high-contrast (`Ctrl+K Ctrl+T`). The two
|
||||
toolbar buttons (enabled, hover, disabled states) restyle and stay legible.
|
||||
10. **Cleanliness.** `git status` shows nothing written to the document, sidecar,
|
||||
or repo by the toolbar gestures except the edits you explicitly accepted
|
||||
(INV-20/35).
|
||||
|
||||
## Pass criteria
|
||||
|
||||
All ten steps behave as described; the adaptive label tracks the live selection;
|
||||
the selection-scoped edit anchors at the right paragraph; the document edit
|
||||
produces per-hunk proposals; pin clears the marks; edit controls are inert on a
|
||||
non-authorable doc; no console errors in the webview devtools; nothing is
|
||||
persisted by the toolbar beyond the edits you accepted.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Manual smoke — F12 document-edit flow
|
||||
|
||||
Covers the document-edit-flow cluster (`specs/coauthoring-document-edit-flow.md`,
|
||||
#42 · #47 · #46). This file is filled in slice by slice.
|
||||
|
||||
## SLICE-1 — #42 (reach): selection-aware Ask-Claude from body + tab (INV-38)
|
||||
|
||||
Run the extension (F5) on a markdown document under the sandbox workspace.
|
||||
|
||||
1. **Body, with selection (PUC-2).** Select a paragraph, right-click the editor
|
||||
**body**. Expect **Ask Claude to Edit Selection** in the menu (and **not**
|
||||
"Edit Document"). Pick it → instruct → submit; a single proposal lands over
|
||||
the selection (existing F11 behavior, unchanged).
|
||||
2. **Body, no selection (PUC-1).** Clear the selection (click once), right-click
|
||||
the editor **body**. Expect **Ask Claude to Edit Document** (and **not** "Edit
|
||||
Selection"). Pick it → instruct → submit; the whole-document rewrite surfaces
|
||||
as F4 proposal(s) in the preview.
|
||||
3. **Tab, with selection (PUC-3).** With a selection active, right-click the
|
||||
editor **tab**. Expect **Ask Claude to Edit Selection**, acting on that tab's
|
||||
document.
|
||||
4. **Tab, no selection (PUC-3).** With no selection, right-click the editor
|
||||
**tab**. Expect **Ask Claude to Edit Document**, acting on **that tab's**
|
||||
document — even if a *different* editor is the active one. Open two markdown
|
||||
tabs A and B, make A active, right-click B's tab → Edit Document → the
|
||||
proposals land on **B**, not A.
|
||||
5. **Markdown-gated.** Open a non-markdown file (e.g. `.txt`). Right-click body or
|
||||
tab: neither **Ask Claude to Edit Selection** nor **Edit Document** appears.
|
||||
6. **Single edit path.** Both entries route through the same `runEditAndPropose`
|
||||
path — there is no second edit code path (INV-38). Nothing is written to the
|
||||
document or sidecar by merely invoking the menu (INV-10/20/35) until you accept.
|
||||
|
||||
### Pass criteria
|
||||
|
||||
The body and tab menus show exactly one Ask-Claude edit entry, matching the live
|
||||
selection state (selection → Edit Selection; none → Edit Document); the tab
|
||||
gesture targets the clicked tab's document, not the active editor; both are absent
|
||||
on non-markdown docs; no console errors.
|
||||
|
||||
## SLICE-2 — #47 (review): per-block proposals + word-precise attribution (INV-39/40/41)
|
||||
|
||||
On a markdown doc with several paragraphs, **Ask Claude to Edit Document** with a
|
||||
light copy-edit instruction (e.g. "tighten the prose, fix typos").
|
||||
|
||||
1. **One proposal per changed block (INV-39).** Claude's pass surfaces as **one
|
||||
✓/✗ block per changed paragraph/header/bullet**, not a flurry of word-level
|
||||
blocks. A paragraph with several word edits is a **single** proposal; the
|
||||
word-level `<ins>`/`<del>` still shows *inside* it. Untouched paragraphs show no
|
||||
proposal.
|
||||
2. **Changed fence is atomic (INV-23).** If Claude edits a code/mermaid fence, it
|
||||
is **one** whole-fence proposal.
|
||||
3. **Accept attributes only the changed words (INV-40).** Accept a block proposal,
|
||||
then toggle the preview to **Authorship**/colors (or re-open in the on-state):
|
||||
only the words Claude actually changed are Claude-colored; the unchanged words
|
||||
in that block keep their prior author. The block is the decision unit; the word
|
||||
is the attribution unit.
|
||||
4. **Inserted block accepts cleanly (INV-41).** If Claude adds a new paragraph,
|
||||
its proposal **accepts** (it is anchored to an adjacent block, never a
|
||||
born-orphaned/zero-width proposal).
|
||||
5. **Undo.** Accepting a block is currently **N undo steps** (one per changed run
|
||||
inside the block) — `Ctrl+Z` repeatedly restores it. (Spec deferred note:
|
||||
single-undo-step grouping is a possible follow-up.)
|
||||
6. **Selection edits unchanged.** Edit *Selection* still produces exactly one
|
||||
proposal over the selection (no block fan-out).
|
||||
|
||||
### Pass criteria
|
||||
|
||||
A document edit yields one in-context proposal per changed block (fences atomic);
|
||||
accepting a block lands the whole block but Claude-attributes only the words it
|
||||
changed; inserted blocks accept; selection edits are unaffected; no console errors.
|
||||
|
||||
## SLICE-3 — #46 (accept): Accept all (INV-42)
|
||||
|
||||
On a markdown doc, **Ask Claude to Edit Document** with a pass that changes
|
||||
**several** blocks, so the preview shows **≥ 2** pending proposals.
|
||||
|
||||
1. **Button appears at ≥2 (PUC-6).** The preview toolbar shows **✓✓ Accept all**
|
||||
only when there are **2 or more** pending proposals (and the doc is authorable,
|
||||
annotations on). With 0–1 pending it is hidden.
|
||||
2. **One gesture applies all.** Click **Accept all**: every pending proposal lands
|
||||
(Claude-attributed per INV-40), the ✓/✗ blocks clear, and a status message
|
||||
reports how many were accepted. No confirmation dialog.
|
||||
3. **Undo restores.** `Ctrl+Z` walks back the applied edits (consistent with
|
||||
single accept; a block accept is itself N steps — see SLICE-2).
|
||||
4. **Orphan-skip + report.** If one proposal's target text was changed by hand
|
||||
first (so it can't anchor), Accept all applies the rest and the report says
|
||||
`… , N skipped (target text changed — undo or reject)`; the orphaned proposal
|
||||
stays pending, its text untouched (never force-applied).
|
||||
5. **Command path.** With no preview panel open, the command palette **Cowriting:
|
||||
Accept All Claude Proposals** (markdown-gated) applies all proposals on the
|
||||
active doc with the same report.
|
||||
|
||||
### Pass criteria
|
||||
|
||||
Accept all is offered only at ≥2 pending; one click applies every resolvable
|
||||
proposal and reports the tally; orphans are skipped (not mangled) and remain
|
||||
pending; the palette command works on the active doc; no console errors.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Implementation Plan: F11 — Preview Toolbar as the Primary Interaction Surface (#43)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md`
|
||||
**Anchor:** Feature `benstull/vscode-cowriting-plugin#43` (F11, `type/feature`, `priority/P1`)
|
||||
**Session:** vscode-cowriting-plugin-0037
|
||||
|
||||
This plan transcribes the spec's §7.2 slicing plan into concrete, file-level
|
||||
tasks. Each slice is independently green (unit + host E2E) before the next. Host
|
||||
E2E is this app's required tier (no browser/deploy stage — a VS Code extension);
|
||||
no LLM in CI (edit turns stubbed). The webview's visual rendering, the adaptive
|
||||
label, and the selection→source DOM lookup are manual-smoke only.
|
||||
|
||||
---
|
||||
|
||||
## SLICE-1 — Pin baseline button + reachability *(the immediate win)*
|
||||
|
||||
Homes the orphaned `cowriting.pinDiffBaseline` command and gives the writer a
|
||||
reachable Pin control in the preview toolbar.
|
||||
|
||||
**Tasks**
|
||||
|
||||
1. **Host message routing** (`src/trackChangesPreview.ts`): extract the inline
|
||||
`onDidReceiveMessage` body into a private `handleWebviewMessage(document, m)`
|
||||
method; add a `pinBaseline` branch that calls `this.diffView.pin(document)`
|
||||
(the *previewed* document — not `activeTextEditor`). The existing
|
||||
`onDidChangeBaseline` subscription already re-renders with cleared marks.
|
||||
2. **Test seam**: add `receiveMessage(uriString, m)` that resolves the doc and
|
||||
calls `handleWebviewMessage`, so host E2E can simulate the raw webview message
|
||||
and exercise the real routing.
|
||||
3. **Webview** (`media/preview.ts` + `.css`): add a `⌖ Pin baseline` button in
|
||||
`#cw-header`; click → `postMessage({ type: "pinBaseline" })`; theme-aware CSS.
|
||||
4. **Shell HTML** (`shellHtml`): add the `<button id="cw-pin">` to the header row.
|
||||
5. **Reachability** (`package.json`): flip `cowriting.pinDiffBaseline`'s
|
||||
`commandPalette` `when` from `false` to `editorLangId == markdown`.
|
||||
|
||||
**Host E2E** (`test/e2e/suite/trackChangesPreview.test.ts` or new f11 suite):
|
||||
open a markdown fixture with a divergent baseline → some block marked; simulate
|
||||
`{type:"pinBaseline"}` via `receiveMessage` → `getLastModel` shows every block
|
||||
`unchanged` (marks cleared) and `getBaseline(key).reason === "pinned"`.
|
||||
|
||||
**Unit:** none new (pure layer untouched this slice).
|
||||
|
||||
---
|
||||
|
||||
## SLICE-2 — Block-offset emission *(INV-36 data layer)*
|
||||
|
||||
Shared pure helper wrapping each rendered block with `data-src-start`/`-end`
|
||||
(source char offsets from `BlockWithRange`) in **both** `renderReview` and
|
||||
`renderPlain`. vscode-free, DOM-free, deterministic (extends INV-22/33). No UI.
|
||||
|
||||
**Tasks**
|
||||
|
||||
1. `src/trackChangesModel.ts`: a shared internal helper that prepends
|
||||
`data-src-start="N" data-src-end="M"` to each block's wrapping element, routed
|
||||
from both render paths using the existing `splitBlocksWithRanges` offsets.
|
||||
2. Skip blocks with no live-source range (deletion-only / proposal blocks).
|
||||
|
||||
**Unit** (`test/trackChangesModel.test.ts`): `data-src-start/end` present and
|
||||
correct on every block for both modes (offsets equal `BlockWithRange` ranges);
|
||||
determinism (same inputs → identical HTML).
|
||||
|
||||
---
|
||||
|
||||
## SLICE-3 — Edit Document button + hunk path *(INV-37 document half)*
|
||||
|
||||
**Tasks**
|
||||
|
||||
1. `src/trackChangesModel.ts`: pure `diffToHunks(currentText, rewrittenText):
|
||||
Array<{ start; end; replacement }>` — vscode-free, deterministic.
|
||||
2. `src/trackChangesPreview.ts`: `runEditAndPropose(document, target, instruction)`
|
||||
private routine; `askClaude`/`document` branch → host `showInputBox` →
|
||||
`runEditTurn` over full text → `diffToHunks` → one F4 `propose()` per hunk.
|
||||
3. `package.json`: register `cowriting.editDocument` (document-scoped), routed
|
||||
through `runEditAndPropose({kind:"document"})`; for `#42` reuse.
|
||||
4. Webview: `✦ Ask Claude to Edit Document` button (no-selection state) →
|
||||
`postMessage({ type: "askClaude", scope: "document" })`.
|
||||
|
||||
**Unit:** `diffToHunks` over fixtures (single hunk → one range; multi-hunk →
|
||||
disjoint ranges + correct replacements; unchanged → zero hunks; whole-doc
|
||||
replacement → one full-range hunk).
|
||||
|
||||
**Host E2E:** simulate `{askClaude, scope:"document"}` with a stubbed multi-hunk
|
||||
rewrite → **N** proposals matching the hunks.
|
||||
|
||||
---
|
||||
|
||||
## SLICE-4 — Adaptive Edit Selection *(INV-37 selection half; INV-36 consumer)*
|
||||
|
||||
**Tasks**
|
||||
|
||||
1. Webview: `selectionchange` listener flips the Ask-Claude label (Edit Selection
|
||||
⇆ Edit Document); selection→nearest-`data-src` ancestor resolution →
|
||||
`postMessage({ type:"askClaude", scope:"selection", start, end })`.
|
||||
2. Host: `askClaude`/`selection` branch → `runEditAndPropose({kind:"range",
|
||||
start, end})` → one `runEditTurn` → one F4 `propose()` over the block-union.
|
||||
3. Edge: selection resolving to no live block → fall back to document scope.
|
||||
|
||||
**Host E2E:** simulate `{askClaude, scope:"selection", start, end}` with a stubbed
|
||||
edit turn → exactly **one** proposal over the resolved range, anchored inline.
|
||||
|
||||
---
|
||||
|
||||
## SLICE-5 — Gateway, edges, tests & docs
|
||||
|
||||
**Tasks**
|
||||
|
||||
1. `package.json`: add `cowriting.showTrackChangesPreview` to `editor/title` with
|
||||
`when: editorLangId == markdown` (minimal right-click gateway).
|
||||
2. Non-authorable disabling: Pin + Ask-Claude controls render disabled when
|
||||
`!isAuthorable(document)`; annotations toggle stays active.
|
||||
3. Host E2E: gateway command opens the panel; controls inert on non-authorable.
|
||||
4. `docs/MANUAL-SMOKE-F11.md` (live smoke script per spec §6.8).
|
||||
5. README F11 section.
|
||||
|
||||
---
|
||||
|
||||
## Done = #43 acceptance (spec §7.3)
|
||||
|
||||
Preview toolbar hosts annotations checkbox + Pin baseline + single adaptive
|
||||
Ask-Claude (Edit Selection ⇆ Edit Document) routing through existing F4/F3/F6;
|
||||
edits surface as proposals (one for a selection, per-hunk for a document
|
||||
rewrite); a right-click entry opens the preview; the pin command is no longer
|
||||
orphaned; unit + host E2E green; live smoke performed once.
|
||||
@@ -0,0 +1,602 @@
|
||||
---
|
||||
status: graduated
|
||||
---
|
||||
# Solution Design: Preview Toolbar as the Primary Interaction Surface (F11)
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Author(s)** | Ben Stull (with Claude) |
|
||||
| **Reviewers / approvers** | Ben Stull |
|
||||
| **Status** | `draft` |
|
||||
| **Version** | v0.1.0 |
|
||||
| **Source artifacts** | Feature `benstull/vscode-cowriting-plugin#43` (F11, `type/feature`, `priority/P1`) · Epic `#1` (closed) · Capture session `vscode-cowriting-plugin-0035` (2026-06-12) · Brainstorming session `vscode-cowriting-plugin-0036` · Builds on (all shipped): F3 `#6` (live attribution), F4 `#12` (propose/accept), F6 `#17`/`#19` (baseline + diff view), F7 `#21`/`#22` (rendered preview), F9 `#27` (authorship preview), F10 `#29` (interactive review preview), F10-followups `#31` (inline-at-anchor proposals) · Coexists with `#41` (right-click → Open Review Panel) and `#42` (right-click → Ask Claude to Edit), both blocked-by this · Parent specs (graduated): `coauthoring-inner-loop.md`, `coauthoring-attribution.md`, `coauthoring-propose-accept.md`, `coauthoring-diff-view.md`, `coauthoring-rendered-preview.md`, `coauthoring-interactive-review.md` · Lineage: `ben.stull/rfc-app#48` |
|
||||
|
||||
**Change log**
|
||||
|
||||
| Date | Version | Change | By |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-06-12 | v0.1.0 | Initial draft — brainstorming session 0036 (from the capture in session 0035). Three forks locked with the operator: block-level preview-selection→source mapping; document edit diffed into per-hunk F4 proposals; #43 lands a minimal right-click→open-preview gateway. | Ben Stull + Claude |
|
||||
|
||||
---
|
||||
|
||||
## 1. Business Context
|
||||
|
||||
### 1.1 Executive Summary
|
||||
|
||||
F10 made the rendered preview the **single review surface** — clean editor on the
|
||||
left, annotated review on the right, with an annotations on/off toggle and
|
||||
inline ✓/✗ on Claude's pending proposals. But the writer still cannot *act* from
|
||||
the preview beyond accepting/rejecting proposals: to **ask Claude to edit**, they
|
||||
jump back to the editor and use a selection-gated context-menu item; to **pin a
|
||||
fresh review baseline** they have *no* reachable control at all (the
|
||||
`cowriting.pinDiffBaseline` command is registered but `when:false`, orphaned
|
||||
since `#34` removed its two-pane host); and whole-document editing doesn't exist.
|
||||
|
||||
F11 makes the **preview toolbar the primary interaction surface**. Beside the
|
||||
existing annotations checkbox — the one control the writer already loves — the
|
||||
toolbar gains a **Pin baseline** button and a **single adaptive "Ask Claude…"
|
||||
button** that reads *Edit Selection* when text is selected in the preview and
|
||||
*Edit Document* when nothing is. The writer reads, asks Claude to edit, and
|
||||
resets the baseline all in one place, mouse-first, without leaving the rendered
|
||||
document. A minimal right-click entry opens the preview, making it the surface
|
||||
the `#41`/`#42` gateways will lead into.
|
||||
|
||||
### 1.2 Background
|
||||
|
||||
The inner loop shipped F2–F5 (threads · attribution · propose/accept ·
|
||||
cross-rung). F6 added the baseline + a native diff toggle; F7 the rendered
|
||||
track-changes preview; F9 an authorship mode; F10 (`#29`) collapsed those into the
|
||||
**single interactive review preview** (clean editor; annotations on/off; ✓/✗ on
|
||||
F4 proposals surfaced in the rendered view); `#31` then placed proposals
|
||||
**inline at their resolved anchor** in that preview.
|
||||
|
||||
Capture session 0035 filed `#43` (this feature) plus `#41` (right-click → Open
|
||||
Review Panel) and `#42` (right-click → Ask Claude to Edit). The operator's ask:
|
||||
*"Can we set it up so all interactions — Ask Claude to Edit Selection / Edit
|
||||
Document, annotations off/on, Pin new baseline — are via the preview window? I
|
||||
like the annotations checkbox up there; make the others buttons, with one 'Ask
|
||||
Claude…' button that changes depending on whether some of the markdown preview is
|
||||
selected."* That session also surfaced the stranded `pinDiffBaseline` command
|
||||
(`when:false` since `#34`). This spec is the Solution Design for `#43`.
|
||||
|
||||
### 1.3 Business Actors / Roles
|
||||
|
||||
- **Coauthor (human)** — the markdown writer/engineer (PP-1); F11's sole user.
|
||||
- **Coauthor (machine)** — Claude via `@cline/sdk`; not a user of F11, but the
|
||||
target of the toolbar's "Ask Claude…" gesture and the author of the proposals
|
||||
that result.
|
||||
|
||||
### 1.4 Problem Statement
|
||||
|
||||
The plugin's interactions are scattered across surfaces and inconsistently
|
||||
reachable. "Ask Claude to Edit Selection" is only a selection-gated **editor**
|
||||
context-menu item; **whole-document editing doesn't exist**; and **Pin Review
|
||||
Baseline** is **unreachable from any UI**. The one control the writer loves — the
|
||||
annotations on/off checkbox in the preview — proves the toolbar is the natural
|
||||
home for these gestures, but it stands alone. A writer reviewing in the preview
|
||||
has to leave it and hunt through editor menus / the palette to act.
|
||||
|
||||
### 1.5 Pain Points
|
||||
|
||||
- **No edit gesture in the preview** — to ask Claude to change anything, the
|
||||
writer leaves the review surface for the editor's right-click menu.
|
||||
- **Whole-document editing is missing** — there is no "edit the whole document"
|
||||
path at all; only a selection-scoped editor command exists.
|
||||
- **Pin baseline is stranded** — the command exists but no menu, keybinding, or
|
||||
palette entry reaches it (`when:false` since `#34`).
|
||||
- **Mouse-first review is broken mid-flow** — the preview is mouse-driven, but
|
||||
acting forces a context-switch to keyboard/menus elsewhere.
|
||||
|
||||
### 1.6 Targeted Business Outcomes
|
||||
|
||||
The preview becomes a **self-contained cockpit** for the inner loop. From its
|
||||
toolbar a writer can toggle annotations (today), **ask Claude to edit** (selection
|
||||
or whole document, via one button that adapts to what's selected), and **pin a
|
||||
fresh baseline** — no context-switching to the editor or command palette. The
|
||||
interaction model consolidates around the surface the writer already prefers, and
|
||||
the stranded pin command gets a real home.
|
||||
|
||||
### 1.7 Scope (business)
|
||||
|
||||
**In scope:** preview-webview toolbar controls — a **Pin baseline** button and a
|
||||
**single adaptive "Ask Claude…" button** (Edit Selection ⇆ Edit Document) — beside
|
||||
the existing annotations checkbox; wiring those controls to the **existing** F4
|
||||
edit seam, F3 attribution, and F6 baseline command; **block-level**
|
||||
preview-selection → source-range mapping (the central design risk); a **new
|
||||
whole-document edit path** whose result is **diffed into per-hunk F4 proposals**;
|
||||
a **minimal right-click → Open Review Preview** entry so the surface is reachable
|
||||
end-to-end; resolving the pin-baseline reachability gap; unit + host-E2E coverage;
|
||||
manual webview smoke.
|
||||
|
||||
**Out of scope (deferred, not forgotten):** **char-precise sub-block** selection
|
||||
mapping (block granularity is the locked v1 — §6.7); the **richer `#41`/`#42`
|
||||
menu sets** (this feature lands only the minimal gateway; `#41`/`#42` expand it);
|
||||
preview→source **scroll-sync** (`#32`); multi-file / batch editing; the Explorer
|
||||
tree affordance; any export / print / copy gesture.
|
||||
|
||||
**Non-goals (firm):** **no new edit / attribution / proposal *model*** — F11
|
||||
reuses F3 `spansFor`, the F4 `propose`/`accept` single-range model, and the F6
|
||||
baseline store; no change to the **sidecar**, the **cross-rung contract**, or
|
||||
`SCHEMA_VERSION`; **no document mutation from the webview** (INV-20/21/34 hold —
|
||||
the sealed webview posts intent only); no LLM/network/credential surface added to
|
||||
the webview (INV-8 untouched — the edit turn runs host-side as today).
|
||||
|
||||
### 1.8 Assumptions · Constraints · Dependencies
|
||||
|
||||
- **Anchor:** Feature `#43` (F11). Builds directly on shipped work: the F7/F10
|
||||
rendered preview + annotations toggle + host↔webview message bus, the F3
|
||||
attribution + F4 propose/accept inner loop (including `#31`'s inline-at-anchor
|
||||
proposal placement), and the F6 baseline store (`cowriting.pinDiffBaseline`,
|
||||
currently unreachable — this feature gives it a home).
|
||||
- **Central design risk (locked):** the preview is a **rendered** sealed webview
|
||||
(markdown-it HTML, strict CSP — F7 INV-21), so "Edit Selection" must map a
|
||||
selection in the rendered preview back to a **source markdown range**. The
|
||||
rendered HTML carries **no** source positions today; only internal block
|
||||
char-offsets exist (`splitBlocksWithRanges` → `BlockWithRange.start/end`). The
|
||||
locked approach is **block-level** mapping: the pure render layer emits
|
||||
`data-src-start`/`data-src-end` on each rendered block; a selection resolves to
|
||||
the union of the live-source blocks it intersects (§6.7, fork 1).
|
||||
- **Constraint (sealed webview):** interactive controls post messages to the
|
||||
extension host, which applies edits/pins via the existing F4 / F6 paths; the
|
||||
webview **never** edits the document, sidecar, or baseline directly. The edit
|
||||
**turn** (LLM call) and the **instruction prompt** run host-side, keeping the
|
||||
webview free of LLM/network/credential surface.
|
||||
- **Coexistence:** the native editor context-menu "Ask Claude to Edit Selection"
|
||||
(`cowriting.editSelection`) stays unchanged; `#41`/`#42` add right-click
|
||||
gateways *into* the preview. F11 lands only a minimal gateway (§6.7, fork 3).
|
||||
- No new persisted artifact; nothing in `.threads/`, the contract, or
|
||||
`SCHEMA_VERSION` changes.
|
||||
|
||||
### 1.9 Business Use Cases
|
||||
|
||||
- **BUC-1 (edit from the preview)** Reviewing in the preview, the writer selects a
|
||||
paragraph in the rendered document and clicks **"Ask Claude to Edit
|
||||
Selection"**; Claude's proposed change appears as a blue ✓/✗ block at that
|
||||
spot — without the writer ever leaving the preview.
|
||||
- **BUC-2 (edit the whole document)** With nothing selected, the writer clicks
|
||||
**"Ask Claude to Edit Document"**, types an instruction, and Claude's rewrite
|
||||
surfaces as **several** independently-acceptable blue proposal blocks (one per
|
||||
changed hunk) inline in the preview.
|
||||
- **BUC-3 (pin a fresh baseline)** After accepting a batch of changes, the writer
|
||||
clicks **"Pin baseline"**; the change-marks clear and "what changed" now counts
|
||||
from this moment — the stranded command finally has a button.
|
||||
|
||||
---
|
||||
|
||||
## 2. Solution Proposal
|
||||
|
||||
F11 is a **thin increment** on F10's preview: it adds two controls to the
|
||||
existing header toolbar and routes their intent through machinery that already
|
||||
exists. No new model, no new persisted state.
|
||||
|
||||
**The pure render layer (`trackChangesModel.ts`) learns one new thing:** a shared
|
||||
helper wraps each rendered block in `<div data-src-start="N" data-src-end="M">`
|
||||
using the existing `splitBlocksWithRanges` offsets. It is applied to **both**
|
||||
render paths — `renderReview` (annotations on) and `renderPlain` (annotations
|
||||
off) — so selection→source mapping works in either mode. The helper is pure,
|
||||
vscode-free, DOM-free, and deterministic (extends INV-22/33). The render layer
|
||||
gains **no** selection or DOM logic.
|
||||
|
||||
**The webview (`media/preview.ts` + `.css`)** header becomes:
|
||||
|
||||
```
|
||||
[ ☑ Annotations ] [ ⌖ Pin baseline ] [ ✦ Ask Claude to Edit Document ▾ ]
|
||||
```
|
||||
|
||||
The **Ask-Claude button morphs its own label** on `selectionchange`: a non-empty
|
||||
selection inside the rendered body → **"Ask Claude to Edit Selection"**; an empty
|
||||
/ collapsed selection → **"Ask Claude to Edit Document"**. On click it walks the
|
||||
selection's start and end nodes up to the nearest ancestor carrying
|
||||
`data-src-start`/`data-src-end` and posts the resolved offsets. That
|
||||
nearest-ancestor lookup is the webview's **only** mapping duty (manual-smoke
|
||||
territory); everything downstream is host-side and testable. The webview stays
|
||||
**sealed** (INV-21): nonce'd inline script, no network, no document mutation.
|
||||
|
||||
**New webview→host messages (intent only):**
|
||||
|
||||
```ts
|
||||
type ToolbarMsg =
|
||||
| { type: "pinBaseline" }
|
||||
| { type: "askClaude"; scope: "selection"; start: number; end: number }
|
||||
| { type: "askClaude"; scope: "document" };
|
||||
```
|
||||
|
||||
**The host (`trackChangesPreview.ts`)** routes each intent through the existing
|
||||
seams:
|
||||
|
||||
- **`pinBaseline`** → pins the **previewed** document (calls
|
||||
`DiffViewController.pin(document)` directly — not the `activeTextEditor`-based
|
||||
command, which may not point at the previewed doc) → `onDidChangeBaseline` →
|
||||
re-render with cleared marks.
|
||||
- **`askClaude`** → host `showInputBox` for the instruction (keeps the LLM /
|
||||
secret surface out of the sealed webview), then one shared host routine
|
||||
`runEditAndPropose(document, target, instruction)`:
|
||||
- **selection** → `target` = the block-union range `[firstBlock.start …
|
||||
lastBlock.end]`; one `runEditTurn` → one F4 `propose()` over that range (the
|
||||
existing Edit-Selection shape).
|
||||
- **document** → `target` = whole document; one `runEditTurn` over the full
|
||||
text → **diff Claude's result against the current text → one `propose()` per
|
||||
changed hunk** (multiple proposals, each its own blue ✓/✗ block). Reuses the
|
||||
F4 single-range model N times; no model change.
|
||||
|
||||
**The right-click gateway:** `cowriting.showTrackChangesPreview` is added to the
|
||||
`editor/title` menu (markdown only) so right-clicking the tab opens the preview —
|
||||
the minimal entry that makes the toolbar surface reachable end-to-end and lets
|
||||
`#41`/`#42` expand the menu set later.
|
||||
|
||||
**Reachability cleanup:** `cowriting.pinDiffBaseline` gets a real palette `when`
|
||||
(`editorLangId == markdown`), resolving the orphan from the command side too; a
|
||||
new `cowriting.editDocument` command is registered (document-scoped edit) so
|
||||
`#42`'s gateway can reuse it.
|
||||
|
||||
Everything downstream of *(intent) → (existing seam)* is the existing F4/F6/F3
|
||||
machinery; the only genuinely new pure code is the block-offset wrapper and the
|
||||
document-rewrite hunk-diff. Both are unit-testable with no vscode and no webview.
|
||||
|
||||
---
|
||||
|
||||
## 3. Product Personas
|
||||
|
||||
- **PP-1 Inner-loop coauthor** — the human markdown writer/engineer (as F2–F10);
|
||||
the only persona F11 serves.
|
||||
|
||||
## 4. Product Use Cases
|
||||
|
||||
- **PUC-1 (toolbar present)** Opening the review preview for a markdown document
|
||||
shows the header with **three** controls: the annotations on/off checkbox
|
||||
(existing), a **Pin baseline** button, and an adaptive **Ask Claude…** button.
|
||||
Controls are inert (disabled) for non-authorable documents.
|
||||
- **PUC-2 (adaptive label)** With a non-empty selection in the rendered preview
|
||||
body, the Ask-Claude button reads **"Ask Claude to Edit Selection"**; with no
|
||||
selection it reads **"Ask Claude to Edit Document"**. The label flips live as
|
||||
the selection changes.
|
||||
- **PUC-3 (edit selection)** The writer selects rendered text, clicks **Ask
|
||||
Claude to Edit Selection**, and enters an instruction. The selection resolves to
|
||||
the union of the source blocks it touches; Claude proposes a change over that
|
||||
range; a single blue ✓/✗ proposal block appears inline at that anchor (`#31`).
|
||||
- **PUC-4 (edit document)** With nothing selected, the writer clicks **Ask Claude
|
||||
to Edit Document**, enters an instruction; Claude rewrites the whole document;
|
||||
the rewrite is diffed into hunks and surfaces as **N** independent blue ✓/✗
|
||||
proposal blocks inline. Accepting/rejecting each is the F10 path unchanged.
|
||||
- **PUC-5 (pin baseline)** The writer clicks **Pin baseline**; the previewed
|
||||
document's review baseline is pinned to now; the change-marks clear and the
|
||||
`Since <epoch>` label updates. (No confirmation prompt — matches the existing
|
||||
command's behavior; re-pinning is the recovery.)
|
||||
- **PUC-6 (right-click into the preview)** Right-clicking a markdown editor tab
|
||||
shows **Open Review Preview**; choosing it opens the preview (the gateway
|
||||
`#41`/`#42` will build upon).
|
||||
- **PUC-7 (graceful edges)** A selection confined to a deletion (struck) or
|
||||
proposal block — which carries no live-source range — falls back to **document**
|
||||
scope. An empty document or a selection that resolves to no live block → the
|
||||
button stays in **Edit Document** mode. A non-authorable document → toolbar edit
|
||||
controls are disabled. The LLM turn failing → the existing `runEditTurn`
|
||||
error handling (no proposal created); the preview is unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 5. UX Layout
|
||||
|
||||
The F10 preview is unchanged except for its **header bar**, which now hosts three
|
||||
controls in a single row:
|
||||
|
||||
- **☑ Annotations** — the existing on/off checkbox (kept first; the operator's
|
||||
preferred control).
|
||||
- **⌖ Pin baseline** — a button; pins the previewed document's review baseline to
|
||||
now and clears the change-marks.
|
||||
- **✦ Ask Claude to Edit Document ▾** — a single button whose label and behavior
|
||||
adapt to the preview's selection state (Edit **Selection** when text is
|
||||
selected, Edit **Document** otherwise). Clicking it opens a host input box for
|
||||
the instruction.
|
||||
|
||||
Buttons are styled with theme CSS variables (light / dark / high-contrast),
|
||||
matching the existing toolbar chrome; they sit in the same `#cw-toggle` header
|
||||
region as the annotations checkbox. When the previewed document is not authorable
|
||||
(F8 `isAuthorable`), the **Pin baseline** and **Ask Claude…** controls render
|
||||
**disabled** (the annotations toggle stays active — reading is always allowed).
|
||||
|
||||
The rendered body is unchanged from F10/`#31`: green human additions, blue
|
||||
LLM-authored text, struck deletions, and pending Claude proposals as inline blue
|
||||
blocks with ✓/✗ at their resolved anchors. Proposals produced via the new toolbar
|
||||
edit gestures appear exactly as proposals do today.
|
||||
|
||||
---
|
||||
|
||||
## 6. Technical Design
|
||||
|
||||
### 6.1 Invariants
|
||||
|
||||
Parent invariants INV-1..INV-34 carry over unchanged. F11 adds:
|
||||
|
||||
- **INV-35 (toolbar gestures route through existing seams; webview never
|
||||
mutates)** The Pin baseline and Ask-Claude toolbar controls post **intent**
|
||||
messages to the host; **all** mutation goes through the existing machinery — pin
|
||||
via the F6 baseline store (`DiffViewController.pin`), edits via the F4
|
||||
`propose` → `accept`/`applyAgentEdit` (`WorkspaceEdit`) seam with F3 attribution.
|
||||
No divergent edit or baseline path is introduced. The sealed webview never
|
||||
edits the document, sidecar, or baseline directly (INV-20/21/34 hold); the LLM
|
||||
turn and the instruction prompt run host-side (INV-8 untouched).
|
||||
- **INV-36 (block-granular preview-selection → source mapping)** The pure render
|
||||
layer emits `data-src-start`/`data-src-end` (source char offsets from
|
||||
`BlockWithRange`) on **every** rendered block, in **both** the on (`renderReview`)
|
||||
and off (`renderPlain`) modes. A preview selection resolves to the **union of
|
||||
the live-source blocks it intersects** (`[min start … max end]`); blocks with no
|
||||
live-source range (deletion-only / proposal blocks) are skipped, and a selection
|
||||
that resolves to no live block falls back to **document** scope. The DOM
|
||||
selection → nearest-`data-src` lookup is the webview's **sole** mapping duty;
|
||||
the offsets and everything downstream (fingerprint, turn, propose) are host-side
|
||||
and testable. The wrapping is deterministic — same inputs → identical HTML
|
||||
(extends INV-22/33).
|
||||
- **INV-37 (single adaptive Ask-Claude button; scope-aware)** One toolbar button
|
||||
serves both scopes. A non-empty live-source selection → **Edit Selection**: one
|
||||
F4 proposal over the block-union range. An empty selection → **Edit Document**:
|
||||
one `runEditTurn` over the whole document, its result **diffed into hunks**, one
|
||||
F4 `propose()` per changed hunk. Both scopes call the same host
|
||||
`runEditAndPropose` routine and reuse the F4 **single-range** proposal model
|
||||
(the document case issues multiple single-range proposals — **no new model**).
|
||||
|
||||
### 6.2 High-level architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
wv["webview header\n☑ Annotations · ⌖ Pin · ✦ Ask Claude (adaptive)"] -- "postMessage{pinBaseline | askClaude(scope,start?,end?)}" --> ctl["trackChangesPreview\n(vscode layer)"]
|
||||
ctl -- "pin(document)" --> base["F6 DiffViewController\nbaseline store (INV-18)"]
|
||||
ctl -- "showInputBox → runEditAndPropose" --> turn["runEditTurn\n(host-side LLM turn)"]
|
||||
turn -- "selection: 1 replacement\ndocument: rewrite" --> ctl
|
||||
ctl -- "selection → 1 propose()\ndocument → diff → N propose()" --> prop["F4 ProposalController\npropose() (single-range model)"]
|
||||
prop -- "onDidChangeProposals" --> ctl
|
||||
base -- "onDidChangeBaseline" --> ctl
|
||||
ctl -- "(baseline, current, spans, proposals)" --> model["renderReview / renderPlain\n(pure)\n+ wrapBlocksWithSrc (NEW)"]
|
||||
model -- "annotated HTML w/ data-src on blocks" --> ctl
|
||||
ctl -- "postMessage{render}" --> wv
|
||||
```
|
||||
|
||||
The dashed-in NEW pieces are: `wrapBlocksWithSrc` (pure), the
|
||||
`runEditAndPropose` host routine with its document-rewrite hunk-diff, the three
|
||||
inbound toolbar messages, and the `editor/title` gateway menu. Everything else is
|
||||
the existing F6/F4/F3/F10 machinery.
|
||||
|
||||
### 6.3 Data model & ownership
|
||||
|
||||
**No new persisted artifact** (INV-20). F11 adds only transient on-the-wire
|
||||
messages (the `ToolbarMsg` union in §2) and reuses F10's `RenderMsg`. Baseline is
|
||||
owned by F6, proposals by F4 (sidecar), attribution by F3 — all untouched. The
|
||||
block-offset `data-src` attributes are render-time only (not stored).
|
||||
|
||||
### 6.4 Interfaces & contracts
|
||||
|
||||
- **`trackChangesModel`** (vscode-free, pure): new
|
||||
`wrapBlocksWithSrc(blocks: BlockWithRange[], renderedPerBlock: string[]):
|
||||
string` (illustrative) — or, more precisely, both `renderReview` and
|
||||
`renderPlain` route their per-block rendered HTML through a shared internal
|
||||
helper that prepends `data-src-start`/`data-src-end` to each block's wrapping
|
||||
element. Plus `diffToHunks(currentText: string, rewrittenText: string):
|
||||
Array<{ start: number; end: number; replacement: string }>` — the pure
|
||||
document-rewrite → per-hunk proposal-range list (vscode-free, deterministic).
|
||||
- **`TrackChangesPreviewController`** (vscode layer): handles the three new
|
||||
inbound messages; gains a `runEditAndPropose(document, target: { kind:
|
||||
"range"; start; end } | { kind: "document" }, instruction)` private routine;
|
||||
takes (or reaches) the `DiffViewController` to pin the previewed doc and the
|
||||
edit-turn entry. New test seams as needed (`getLastModel` already exists for
|
||||
asserting marks without webview DOM).
|
||||
- **`DiffViewController`** (F6): `pin(document)` is reused as-is (the controller
|
||||
already exposes pinning by document); `cowriting.pinDiffBaseline`'s
|
||||
`package.json` `when` flips from `false` to `editorLangId == markdown`.
|
||||
- **`ProposalController`** (F4): `propose(...)` reused unchanged (called once for
|
||||
selection, N times for a diffed document). No signature change.
|
||||
- **`AttributionController`** (F3): unchanged (`applyAgentEdit` reused on accept).
|
||||
- **Commands / menus (`package.json`):**
|
||||
- `cowriting.showTrackChangesPreview` — added to `editor/title` with
|
||||
`when: editorLangId == markdown` (the minimal gateway). Existing palette +
|
||||
`ctrl+alt+r` kept.
|
||||
- `cowriting.pinDiffBaseline` — `when` flips to `editorLangId == markdown`
|
||||
(no longer orphaned).
|
||||
- `cowriting.editDocument` ("Ask Claude to Edit Document", document-scoped) —
|
||||
new command registered, routed through `runEditAndPropose({kind:"document"})`;
|
||||
available for `#42` to reuse. (The preview's selection-scoped edit is driven
|
||||
by the `askClaude` message carrying webview-resolved offsets, not a command,
|
||||
since the offsets originate in the webview.)
|
||||
- **Webview asset** (`media/preview.ts` + `.css`): header gains the two buttons;
|
||||
a `selectionchange` listener updates the Ask-Claude label; click handlers post
|
||||
the `ToolbarMsg` intents; the selection→nearest-`data-src` lookup helper. Stays
|
||||
sealed (nonce'd inline script, CSP unchanged).
|
||||
|
||||
### 6.5 Per–Product-Use-Case design
|
||||
|
||||
- **PUC-1 (toolbar present):** render the two buttons in the header next to the
|
||||
annotations checkbox; disable Pin + Ask-Claude when `!isAuthorable(document)`.
|
||||
- **PUC-2 (adaptive label):** webview `selectionchange` → if the selection is
|
||||
non-empty and within the rendered body, label = "Edit Selection"; else "Edit
|
||||
Document". Pure webview-local state.
|
||||
- **PUC-3 (edit selection):** webview resolves selection → `{start, end}` from the
|
||||
nearest `data-src` ancestors → `postMessage{askClaude, selection, start, end}` →
|
||||
host `showInputBox` → `runEditAndPropose({kind:"range", start, end})` →
|
||||
`runEditTurn` → one `propose()` → `onDidChangeProposals` → re-render (inline
|
||||
blue block at the anchor, `#31`).
|
||||
- **PUC-4 (edit document):** `postMessage{askClaude, document}` → host input box →
|
||||
`runEditAndPropose({kind:"document"})` → `runEditTurn` over full text →
|
||||
`diffToHunks(current, rewritten)` → one `propose()` per hunk → re-render (N blue
|
||||
blocks).
|
||||
- **PUC-5 (pin baseline):** `postMessage{pinBaseline}` → `DiffViewController.pin(
|
||||
previewedDocument)` → `onDidChangeBaseline` → re-render (marks cleared).
|
||||
- **PUC-6 (right-click gateway):** `editor/title` entry invokes
|
||||
`cowriting.showTrackChangesPreview` for the tab's document.
|
||||
- **PUC-7 (edges):** selection resolving to no live block → document scope;
|
||||
non-authorable → controls disabled; `runEditTurn` failure → existing error path,
|
||||
no proposal; empty doc → Edit Document over empty range (no-op-safe).
|
||||
|
||||
### 6.6 Non-functional requirements & cross-cutting concerns
|
||||
|
||||
The webview stays **sealed** (INV-21): local assets, strict CSP with a per-load
|
||||
nonce, no network; the new inline handlers only read `data-src`/`data-proposal-id`
|
||||
and post intent (no eval, no remote, no document mutation). The instruction prompt
|
||||
and the LLM turn remain **host-side** — the webview gains **no** LLM, network, or
|
||||
credential surface (INV-8 untouched). `diffToHunks` and the block wrapping are
|
||||
O(document), run on a host gesture (not per-keystroke), fine at inner-loop scale.
|
||||
No telemetry, nothing persisted.
|
||||
|
||||
### 6.7 Key decisions & alternatives considered
|
||||
|
||||
| Decision | Chosen | Alternatives rejected |
|
||||
| --- | --- | --- |
|
||||
| **Preview-selection → source mapping granularity** | **Block-level** — pure layer emits `data-src-start/end` from existing `BlockWithRange`; selection → union of intersected live-source blocks. Robust, reuses what exists, ships the full adaptive button now. *(Operator decision, session 0036.)* | **Char-precise** sub-block mapping — needs per-inline-token source offsets markdown-it doesn't reliably give; rendered text ≠ source (syntax stripped) → fragile, risks the whole feature on the hardest part. **Document-only first** — defers the headline adaptive button; punts the risk. |
|
||||
| **Document-edit proposal granularity** | **Diff Claude's rewrite into hunks → one F4 proposal per changed hunk** — independent ✓/✗ per change; reuses the single-range model N times (no model change). *(Operator decision, session 0036.)* | **One whole-document proposal** — a single giant blue block, all-or-nothing accept/reject; poor UX for a real rewrite. |
|
||||
| **`#43` vs `#41`/`#42` scope** | **`#43` lands a minimal right-click → Open Review Preview gateway** (`editor/title`), so the toolbar surface is reachable end-to-end and its E2E is real; `#41`/`#42` expand the menu set. *(Operator decision, session 0036.)* | **Toolbar only; all menus in `#41`/`#42`** — `#43`'s "a right-click entry opens the preview" acceptance/E2E couldn't be satisfied within `#43`. |
|
||||
| **Instruction prompt location** | **Host `showInputBox`** — keeps LLM/secret surface out of the sealed webview; reuses the existing edit-turn flow. | **In-webview text field** — pushes prompt handling toward the sandbox; no benefit. |
|
||||
| **Pin button target** | **The previewed document** (`DiffViewController.pin(document)`) — the preview knows its bound doc. | **`activeTextEditor`-based command** — may not point at the previewed doc; the source of the orphan. |
|
||||
| **Pin confirmation** | **No confirm** — matches the existing command; re-pinning recovers. | **Confirm dialog** — friction for a routine, recoverable gesture. |
|
||||
|
||||
### 6.8 Testing strategy
|
||||
|
||||
- **Unit (vitest, vscode-free):** `data-src-start/end` present and correct on every
|
||||
block for **both** `renderReview` and `renderPlain` (offsets equal the
|
||||
`BlockWithRange` ranges; determinism — same inputs → identical HTML);
|
||||
`diffToHunks` over fixtures — a single-hunk rewrite → one range; a multi-hunk
|
||||
rewrite → the expected disjoint ranges with correct replacements; an unchanged
|
||||
rewrite → zero hunks; whole-document replacement → one full-range hunk.
|
||||
- **Host E2E (`@vscode/test-electron`, no LLM, extends the F10 suite):** open a
|
||||
markdown fixture → `cowriting.showTrackChangesPreview`. Simulate
|
||||
`{type:"pinBaseline"}` → `getLastModel` shows cleared change-marks + advanced
|
||||
epoch. Simulate `{type:"askClaude", scope:"selection", start, end}` with a
|
||||
stubbed edit turn → exactly **one** proposal over the resolved range, anchored
|
||||
inline. Simulate `{type:"askClaude", scope:"document"}` with a stubbed
|
||||
multi-hunk rewrite → **N** proposals matching the hunks. Invoke the
|
||||
`editor/title` gateway command → panel opens. Non-authorable document → toolbar
|
||||
edit controls disabled (asserted via the model/flags the host exposes). The
|
||||
webview DOM, real button clicks, the `selectionchange` label flip, and the
|
||||
selection→`data-src` lookup are **not** E2E-asserted (sealed sandbox) — manual
|
||||
smoke.
|
||||
- **Live smoke (manual — `docs/MANUAL-SMOKE-F11.md`):** open a markdown doc; open
|
||||
the review preview; confirm the three header controls; select a paragraph →
|
||||
button reads "Edit Selection", click → enter instruction → a blue ✓/✗ block
|
||||
appears at that paragraph; clear the selection → button reads "Edit Document",
|
||||
click → instruction → several blue blocks appear; click **Pin baseline** → marks
|
||||
clear, `Since` label updates; right-click the tab → **Open Review Preview** opens
|
||||
the panel; verify light/dark theming and that `git status` shows nothing
|
||||
unexpected.
|
||||
|
||||
### 6.9 Failure modes, rollback & flags
|
||||
|
||||
A selection that resolves to no live-source block → **Edit Document** scope (never
|
||||
an error). `runEditTurn` failing → existing error handling, no proposal created,
|
||||
preview unchanged. `diffToHunks` producing zero hunks (rewrite == current) → no
|
||||
proposals, a brief "no changes proposed" host notice. Webview disposed mid-gesture
|
||||
→ the host routine completes against the document; the next open re-renders.
|
||||
**No feature flag** — the toolbar controls are additive UI; nothing persists.
|
||||
Rollback is reverting the PR with **zero** data migration (nothing persisted; the
|
||||
F6 baseline, F4 sidecar, F3 attribution data are untouched; the unhidden pin
|
||||
command and the gateway menu simply disappear).
|
||||
|
||||
---
|
||||
|
||||
## 7. Delivery Plan
|
||||
|
||||
### 7.1 Approach / strategy
|
||||
|
||||
One planning-and-executing session (F11 = `#43`), plan written just-in-time from
|
||||
this spec — the F2–F10 precedent. Host-E2E tier (a VS Code extension has no
|
||||
browser/deploy stage); no LLM in CI (edit turns stubbed). The webview's visual
|
||||
rendering, the adaptive label, and the selection→source DOM lookup are verified by
|
||||
the manual smoke; the automated seams are the pure block-wrapping + `diffToHunks`
|
||||
model and the host's message→seam wiring.
|
||||
|
||||
### 7.2 Slicing plan
|
||||
|
||||
- **SLICE-1 — Pin baseline button + reachability.** Webview header **Pin
|
||||
baseline** button → `{type:"pinBaseline"}` → host `DiffViewController.pin(
|
||||
previewedDoc)`; unhide `cowriting.pinDiffBaseline` (`when: editorLangId ==
|
||||
markdown`). Host E2E: pin message clears marks. *(Immediate win — homes the
|
||||
orphaned command.)*
|
||||
- **SLICE-2 — Block-offset emission.** Shared pure helper wrapping each block with
|
||||
`data-src-start/end` in `renderReview` **and** `renderPlain`; vitest for both
|
||||
modes + determinism. No UI yet. (INV-36 data layer.)
|
||||
- **SLICE-3 — Edit Document button + hunk path.** Webview **Ask Claude to Edit
|
||||
Document** button (no-selection state) → `{type:"askClaude", scope:"document"}`;
|
||||
host `runEditAndPropose({document})` → `runEditTurn` → `diffToHunks` → N
|
||||
`propose()`; register `cowriting.editDocument`; vitest for `diffToHunks`; host
|
||||
E2E for the N-proposal path. (INV-37 document half.)
|
||||
- **SLICE-4 — Adaptive Edit Selection.** Webview `selectionchange` label flip +
|
||||
selection→nearest-`data-src` resolution → `{type:"askClaude", scope:"selection",
|
||||
start, end}`; host single-range `propose()`. Host E2E for the selection message →
|
||||
one anchored proposal. (INV-37 selection half; INV-36 consumer.)
|
||||
- **SLICE-5 — Gateway, edges, tests & docs.** `editor/title` → Open Review Preview
|
||||
gateway; non-authorable disabling; host E2E (gateway opens panel; controls
|
||||
inert on non-authorable); `docs/MANUAL-SMOKE-F11.md`; README F11 section.
|
||||
|
||||
E2E are first-class plan tasks (handbook §9/§4); this app's required tier is host
|
||||
E2E (the F2–F10 precedent).
|
||||
|
||||
### 7.3 Rollout / launch plan
|
||||
|
||||
Non-shippable (no marketplace publish). "Done" = `#43` acceptance: the preview
|
||||
toolbar hosts the annotations checkbox + a Pin baseline button + a single adaptive
|
||||
Ask-Claude button (Edit Selection ⇆ Edit Document) that route through the existing
|
||||
F4/F3/F6 machinery; edits surface as proposals (one for a selection, per-hunk for
|
||||
a document rewrite); a right-click entry opens the preview; the pin command is no
|
||||
longer orphaned; unit + host E2E green; live smoke performed once.
|
||||
|
||||
### 7.4 Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| --- | --- |
|
||||
| Block-level selection feels coarse vs the editor's char-precise Edit Selection | Locked v1 decision (§6.7); a rendered surface is naturally block-grained; char-precise is a deferred follow-up if the coarseness bites |
|
||||
| `data-src` attributes perturb markdown-it output or the F10 proposal/diff rendering | Wrapping is applied at the block boundary (outside inline parsing); covered by determinism + both-mode unit tests; per-block `try/catch` error chip (F7) on render failure |
|
||||
| `diffToHunks` produces awkward hunk boundaries on a large rewrite | Pure + unit-tested over fixtures; hunks are line/block-aligned; worst case is more/fewer blocks, all independently ✓/✗-able — never wrong, just granular |
|
||||
| Selection inside a deletion/proposal block has no live-source range | Falls back to Document scope by design (INV-36); manual-smoke verified |
|
||||
| The webview selection→`data-src` lookup isn't E2E-testable (sealed) | The host half (offsets→fingerprint→propose) is E2E'd via simulated messages; the DOM lookup is the only manual-smoke-only seam, kept deliberately thin |
|
||||
| Unhiding pin / adding `editDocument` widens the command surface | Both guard on `editorLangId == markdown`; both route through existing seams; no new model or persisted state |
|
||||
|
||||
---
|
||||
|
||||
## 8. Traceability matrix
|
||||
|
||||
| Requirement (`#43`) | Use case | Design | Slice |
|
||||
| --- | --- | --- | --- |
|
||||
| Pin baseline button in the preview toolbar | PUC-5 | INV-35, §6.4 (`DiffViewController.pin`) | SLICE-1 |
|
||||
| Resolve the orphaned `pinDiffBaseline` reachability | PUC-5 | §6.4 (`when` flip) | SLICE-1 |
|
||||
| Single adaptive Ask-Claude button (Selection ⇆ Document) | PUC-2/3/4 | INV-37, §6.2 | SLICE-3/4 |
|
||||
| Preview-selection → source range mapping (block-level) | PUC-3 | INV-36, §6.7 | SLICE-2/4 |
|
||||
| Edit Document path (new whole-document edit) | PUC-4 | INV-37, §6.5 (hunk diff) | SLICE-3 |
|
||||
| Edits route through existing F4/F3 (no divergent path) | PUC-3/4 | INV-35, §6.4 | SLICE-3/4 |
|
||||
| Right-click entry opens the preview (minimal gateway) | PUC-6 | §6.4 (`editor/title`) | SLICE-5 |
|
||||
| Controls only active for supported (authorable) docs | PUC-1/7 | §6.5 | SLICE-5 |
|
||||
| Sealed webview, no document mutation / LLM surface | — | INV-21/35, §6.6 | all |
|
||||
| No new edit/attribution/proposal model | — | §1.7, INV-37 | all |
|
||||
| Unit + host E2E + right-click-opens-preview coverage | — | §6.8 | SLICE-1..5 |
|
||||
|
||||
## 9. Open Questions & Decisions log
|
||||
|
||||
- **RESOLVED (session 0036, operator):** preview-selection → source mapping =
|
||||
**block-level** (`data-src` attributes from `BlockWithRange`; union of
|
||||
intersected blocks); document edit = **diffed into per-hunk F4 proposals**;
|
||||
`#43` **lands a minimal right-click → Open Review Preview gateway** (`#41`/`#42`
|
||||
expand the menus).
|
||||
- **RESOLVED (this spec, autonomous):** instruction prompt = **host `showInputBox`**
|
||||
(LLM/secrets stay out of the webview); Pin targets the **previewed document**
|
||||
(`DiffViewController.pin`); **no confirmation** on pin (matches existing); a new
|
||||
`cowriting.editDocument` command is registered for `#42` reuse; `pinDiffBaseline`
|
||||
is unhidden (`editorLangId == markdown`).
|
||||
- **OPEN → later:** **char-precise** sub-block selection mapping (deferred — block
|
||||
granularity is v1); the **richer `#41`/`#42` menu sets** (this lands only the
|
||||
minimal gateway); preview→source **scroll-sync** (`#32`); whether a large
|
||||
document rewrite should cap/segment its hunks (only if real rewrites prove
|
||||
noisy); the repo rename to `vscode-markdown-cowriting-plugin` (`#35`, deferred).
|
||||
|
||||
## 10. Glossary & References
|
||||
|
||||
- **Preview toolbar** — the review preview's header row: the annotations on/off
|
||||
checkbox (existing) plus F11's Pin baseline and adaptive Ask-Claude buttons.
|
||||
**Adaptive Ask-Claude button** — one button reading "Edit Selection" (non-empty
|
||||
preview selection) or "Edit Document" (none). **Block-level selection mapping** —
|
||||
resolving a rendered-preview selection to the union of source blocks it
|
||||
intersects, via `data-src-start/end` attributes emitted by the pure render
|
||||
layer. **Hunk-diffed document edit** — Claude's whole-document rewrite split
|
||||
into changed hunks, each surfaced as its own F4 proposal. **Pin baseline** — F6's
|
||||
`DiffViewController.pin` applied to the previewed document. **Gateway** — a
|
||||
right-click entry that opens the preview (this feature lands the minimal one;
|
||||
`#41`/`#42` expand them).
|
||||
- Feature `#43` (F11) · Epic `#1` · builds on F3 `#6`, F4 `#12`, F6 `#17`/`#19`,
|
||||
F7 `#21`/`#22`, F9 `#27`, F10 `#29`/`#31` · coexists with `#41`/`#42` · parent
|
||||
specs `coauthoring-inner-loop.md`, `coauthoring-attribution.md`,
|
||||
`coauthoring-propose-accept.md`, `coauthoring-diff-view.md`,
|
||||
`coauthoring-rendered-preview.md`, `coauthoring-interactive-review.md` · capture
|
||||
session 0035 · lineage `ben.stull/rfc-app#48`.
|
||||
@@ -59,6 +59,19 @@ pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
|
||||
#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 {
|
||||
|
||||
@@ -18,6 +18,8 @@ interface RenderMessage {
|
||||
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();
|
||||
@@ -26,12 +28,72 @@ 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");
|
||||
@@ -69,8 +131,16 @@ 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;
|
||||
|
||||
+69
-18
@@ -18,6 +18,16 @@
|
||||
"onStartupFinished"
|
||||
],
|
||||
"contributes": {
|
||||
"configuration": {
|
||||
"title": "Cowriting",
|
||||
"properties": {
|
||||
"cowriting.liveProgress.revealOutput": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"command": "cowriting.showClineSdkInfo",
|
||||
@@ -69,19 +79,24 @@
|
||||
"title": "Propose Agent Edit (internal seam)",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.toggleDiffView",
|
||||
"title": "Cowriting: Toggle Diff View",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.pinDiffBaseline",
|
||||
"title": "Cowriting: Pin Diff Baseline to Now",
|
||||
"title": "Cowriting: Pin Review Baseline to Now",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"title": "Cowriting: Open Review Preview",
|
||||
"title": "Open Cowriting Review Panel",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"title": "Ask Claude to Edit Document",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.acceptAllProposals",
|
||||
"title": "Accept All Claude Proposals",
|
||||
"category": "Cowriting"
|
||||
}
|
||||
],
|
||||
@@ -104,18 +119,58 @@
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.toggleDiffView",
|
||||
"when": "false"
|
||||
"command": "cowriting.pinDiffBaseline",
|
||||
"when": "editorLangId == markdown"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.pinDiffBaseline",
|
||||
"when": "false"
|
||||
"command": "cowriting.editDocument",
|
||||
"when": "editorLangId == markdown"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.acceptAllProposals",
|
||||
"when": "editorLangId == markdown"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"when": "editorLangId == markdown",
|
||||
"group": "navigation@9"
|
||||
}
|
||||
],
|
||||
"editor/title/context": [
|
||||
{
|
||||
"command": "cowriting.editSelection",
|
||||
"when": "editorHasSelection && resourceLangId == markdown",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"when": "!editorHasSelection && resourceLangId == markdown",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"when": "resourceLangId == markdown",
|
||||
"group": "1_cowriting@3"
|
||||
}
|
||||
],
|
||||
"explorer/context": [
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"when": "resourceLangId == markdown",
|
||||
"group": "navigation@9"
|
||||
}
|
||||
],
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "cowriting.editSelection",
|
||||
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)",
|
||||
"when": "editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"when": "!editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
@@ -145,11 +200,6 @@
|
||||
]
|
||||
},
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "cowriting.toggleDiffView",
|
||||
"key": "ctrl+alt+d",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"key": "ctrl+alt+r",
|
||||
@@ -162,7 +212,8 @@
|
||||
"watch": "node esbuild.mjs --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
|
||||
"clean:e2e": "node -e \"require('fs').rmSync('out/test',{recursive:true,force:true})\"",
|
||||
"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",
|
||||
"vscode:prepublish": "node esbuild.mjs"
|
||||
|
||||
@@ -10,7 +10,15 @@ console.log(`instruction: ${instruction}`);
|
||||
console.log(`text: ${text}`);
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const result = await runEditTurn(instruction, text);
|
||||
// #60: exercise the live-progress path against the real SDK — log each reduced
|
||||
// snapshot so the smoke shows streaming/activity/tokens, not just the result.
|
||||
const result = await runEditTurn(instruction, text, {
|
||||
onProgress: (s) => {
|
||||
const bits = [s.phase === "tool" ? `tool:${s.tool}` : s.phase, `${s.chars}c`];
|
||||
if (s.tokens) bits.push(`${s.tokens}tok`);
|
||||
console.log(` progress: ${bits.join(" ")}`);
|
||||
},
|
||||
});
|
||||
console.log(`replacement: ${JSON.stringify(result.replacement)}`);
|
||||
console.log(`model: ${result.model}`);
|
||||
console.log(`sessionId: ${result.sessionId}`);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Session 0032.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-12T01-20 (PST)
|
||||
> End: 2026-06-12T03-05 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
"What's next?" → operator chose: **tidy loose ends, then plan-and-execute #31**.
|
||||
Then "keep going" → continued into #34, then #33.
|
||||
|
||||
## Pre-state
|
||||
|
||||
On `main`, but the baseline was not clean: local `main` behind `origin/main` by 3
|
||||
(session-transcript commits — this app keeps `sessions/` in the code repo), an
|
||||
untracked `specs/` dir, an abandoned worktree `pedantic-brahmagupta-472309`
|
||||
(106 commits behind), and two merged-but-undeleted remote branches. Memory pointed
|
||||
to plan-and-execute #31 next.
|
||||
|
||||
## Arc
|
||||
|
||||
### 1. Loose-ends tidy
|
||||
- Fast-forwarded `main`; synced the 0032 claim.
|
||||
- F10 spec `coauthoring-interactive-review.md` was missing from the content repo —
|
||||
placed it in the content repo **working tree** (left uncommitted; that repo has
|
||||
operator-owned capture drafts + a diff-view.md edit pending the operator's publish).
|
||||
- Removed the stray code-repo `specs/` (one dup of content, one now-placed).
|
||||
- Removed the abandoned worktree (untracked `prototype/` + `.claude/` snapshotted
|
||||
to `~/.wgl/abandoned-worktree-snapshots/pedantic-brahmagupta-472309/`).
|
||||
- Deleted the two merged remote branches.
|
||||
- **Correction:** the "0024 unfinalized" loose end carried in prior memory is
|
||||
FALSE — 0024 has a finalized transcript. Dropped from memory.
|
||||
|
||||
### 2. #31 — inline-at-anchor proposals (PR #36, merged; issue closed)
|
||||
TDD. `renderReview` now associates each resolved proposal with the current-side
|
||||
block its `anchorStart` falls in and emits the `cw-proposal` block right after that
|
||||
block; same-block order by anchorStart then id; unresolved/unplaceable still trail
|
||||
(INV-33/34). Implements the placement the F10 spec §2/§6.2 already specified (the
|
||||
#29 trailing-block was a recorded v1 deferral). +5 unit tests, +1 E2E in-place
|
||||
assertion. One pre-existing F7 E2E flake (fixed-timeout `settle()` race) passed on
|
||||
re-run — unrelated to the change (different code path).
|
||||
|
||||
### 3. #34 — delete F6 dead two-pane view (PR #37, merged; issue closed)
|
||||
Removed the dead `vscode.diff` VIEW: `DiffViewController.toggle`/`findDiffTab`/
|
||||
`epochLabel`/`isDiffOpen`, the `cowriting-baseline:` content provider +
|
||||
`BASELINE_SCHEME` + `baselineUri` + content-provider emitter, the `toggleDiffView`
|
||||
command + `ctrl+alt+d` keybinding. Kept the baseline DATA layer fully intact
|
||||
(ensureBaseline/advance/pin/capture, getBaseline, onDidChangeBaseline, persistence
|
||||
INV-19, machine-landing advance INV-18). Rewrote the diffView E2E suite to the
|
||||
baseline-data-layer tests; flipped F10 + no-workspace assertions to "toggleDiffView
|
||||
absent."
|
||||
|
||||
### 4. #33 — intra-emphasis sentinel hardening (characterized, DEFERRED)
|
||||
Reproduced two failure modes with the real renderer (recorded as issue #33
|
||||
comment). Operator chose to defer to its own session. No code changed.
|
||||
|
||||
## Cut state
|
||||
|
||||
`main` clean and synced; 0 open PRs. #31 + #34 shipped and closed. 194 unit + 49
|
||||
E2E green; typecheck + build clean. No deploy pipeline (VS Code extension, no
|
||||
flotilla/PPE).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
1. **Kept `pinDiffBaseline` in #34 against the issue's literal acceptance.**
|
||||
Alternative: remove it as the issue listed. Why: the canonical Solution Design
|
||||
(spec §6.7) scopes the removal to the two-pane VIEW only ("keep the controller +
|
||||
baseline store"); `pin()` is a §6.4 baseline-lifecycle op, never touches
|
||||
`vscode.diff`, and is exercised by live F7 baseline-reset tests. Spec wins over
|
||||
the P3 capture draft (documentation-leads-automation). Logged for operator
|
||||
awareness.
|
||||
2. **F10 spec left UNCOMMITTED in the content repo working tree** rather than
|
||||
committed/pushed. Alternative: push it. Why: the content repo has operator-owned
|
||||
pending work (capture issue-drafts + a diff-view.md edit); content publishing is
|
||||
the Author's gesture (per wgl-capture). Surfaced to the operator.
|
||||
3. **Discarded the abandoned worktree's untracked files** (prototype/index.html +
|
||||
.claude config). Snapshot-and-proceed: copied to
|
||||
`~/.wgl/abandoned-worktree-snapshots/` before `git worktree remove --force`.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal plan-and-execute #33 (intra-emphasis sentinel hardening) via the token-aware HTML-post-process approach recommended in the issue #33 comment, per specs/coauthoring-interactive-review.md §1.7/§9
|
||||
```
|
||||
|
||||
(Alternatives in the backlog: #32 scroll-sync needs a Solution Design first
|
||||
(brainstorming); #35 repo rename is an outward admin gesture awaiting operator
|
||||
timing.)
|
||||
@@ -0,0 +1,59 @@
|
||||
# Session 0033.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-12T03-14 (PST)
|
||||
> End: 2026-06-12T03-27 (PST)
|
||||
> Type: capture (tracked-lite)
|
||||
> Posture: careful (capture default — output operator-reviewed)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture` — "Undo doesn't render correctly in the preview pane".
|
||||
|
||||
## Arc
|
||||
|
||||
A capture session turning one operator observation into a filed, typed,
|
||||
source-linked Gitea issue.
|
||||
|
||||
- **Clarified the symptom** (AskUserQuestion): "updates but wrong marks" in the
|
||||
**F10 review preview**, reproduces **always** on undo. Reflected back the read
|
||||
that undo reverts the buffer (text updates) but the overlay marks (since-baseline
|
||||
diff / F3 author-coloring / F4 proposal blocks) go stale/wrong.
|
||||
- **Drafted** `issues/undo-renders-wrong-marks-in-preview.md` in the content repo
|
||||
working tree (terse, bug-depth §5 template). Classified **type/bug**.
|
||||
- **Type/bug vs the tool:** `capture-issues.sh` only accepts
|
||||
`epic|feature|story|task`, so it can't file a bug — even though the tracker has a
|
||||
`type/bug` label and handbook §4.3's gate vocabulary includes "leaf
|
||||
story/task/bug." Declined to mis-type the defect as a task to fit the tool.
|
||||
- **Filed `#38`** (type/bug, priority/P1 — operator decision) via `gitea-api.sh`
|
||||
directly (labels API), since the deterministic capture script couldn't express
|
||||
`bug`. → https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/38
|
||||
- **Filed plugin feedback `#124`** (friction/medium) on the plugin tracker for the
|
||||
capture-can't-type-bugs gap, framed as a taxonomy inconsistency to reconcile
|
||||
across the Capture RFC §4, `ensure-capture-labels.sh`, `capture-issues.sh`,
|
||||
`wgl-capture/SKILL.md`, and handbook §4.3.
|
||||
|
||||
## Cut state
|
||||
|
||||
Code repo clean on `main`. One issue filed (#38) + one plugin-feedback issue (#124).
|
||||
Draft `undo-renders-wrong-marks-in-preview.md` left uncommitted in the content repo
|
||||
working tree (operator's to publish — INV-8), alongside the other pending capture
|
||||
drafts + the F10 spec placed in session 0032.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
1. **Filed #38 with `type/bug` via a `gitea-api.sh` bypass** rather than
|
||||
`capture-issues.sh`. Alternative: type it `task` to stay within the tool. Why:
|
||||
the defect's honest type is `bug` (label exists; §4.3 recognizes it); the script
|
||||
gap shouldn't drive mis-classification. The gap itself was logged as plugin
|
||||
feedback #124. Priority P1 was the operator's explicit call.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal plan-and-execute #38 (undo renders wrong marks in the F10 review preview, P1) — reproduce the wrong-marks-after-undo case, find which layer (F3 attribution spans / F4 proposal anchors) goes stale on undo and reconcile it, confirm across edit→undo→redo
|
||||
```
|
||||
|
||||
(P1 — supersedes session 0032's #33 pointer. #33 intra-emphasis hardening (P3) and
|
||||
#32 scroll-sync (feature, needs a Solution Design) remain open.)
|
||||
@@ -0,0 +1,63 @@
|
||||
# Session 0034.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-12T03-28 (PST)
|
||||
> End: 2026-06-12T03-39 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/goal next` → resolved from memory to: plan-and-execute #38 (undo renders wrong
|
||||
marks in the F10 review preview, P1).
|
||||
|
||||
## Plan
|
||||
|
||||
Fix #38 (P1). Root cause (confirmed by reading + E2E repro): attribution attributed
|
||||
every non-seam change to currentAuthor() (always human), ignoring e.reason — so
|
||||
undo/redo re-inserting text falsely colored it human. Fix: geometry-only span
|
||||
reconciliation on undo/redo (no fresh attribution). E2E repro → minimal fix → green.
|
||||
|
||||
## Arc (systematic debugging)
|
||||
|
||||
- **Phase 1 (root cause):** traced the three data sources feeding the pure
|
||||
`renderReview` (baseline diff / F3 author spans / F4 proposals). Read
|
||||
`attributionController.onDidChange` + `attributionTracker.applyChange`: every
|
||||
non-seam change is attributed to `currentAuthor()` (always human) and `e.reason`
|
||||
is ignored. Proposals only shift (not the culprit). Disk-sync guard masks
|
||||
undo-to-saved-state → bug is on mid-edit undo.
|
||||
- **Reproduce:** wrote `test/e2e/suite/undoMarks.test.ts` driving edit→edit→undo
|
||||
(mid-edit, buffer stays dirty). Confirmed FAILING: after undo, `spansFor` =
|
||||
`[{start:6,end:12,author:"human"}]` over restored "bravo " — false attribution.
|
||||
- **Fix (Phase 4):** `applyChange` gains `attributeInserted` (default true);
|
||||
`onDidChange` sets it false and skips seam matching when
|
||||
`e.reason === Undo|Redo`. Restored text stays neutral, not falsely human.
|
||||
- **Verify:** repro passes; +3 unit tests for the geometric-only path. 197 unit +
|
||||
50 E2E green; typecheck clean.
|
||||
- **Shipped:** PR #39 merged → main; issue #38 closed.
|
||||
- **Follow-up filed #40** (type/task, P3): restore *exact* prior provenance on
|
||||
undo/redo (the fix leaves undone text neutral, not its original author color);
|
||||
needs an attribution history stack synced to the editor undo stack.
|
||||
|
||||
## Cut state
|
||||
|
||||
`main` clean + synced; 0 open PRs. #38 fixed and closed; #40 filed. 197 unit + 50
|
||||
E2E green. No deploy pipeline (VS Code extension).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
1. **Scoped the fix to "neutral on undo," not exact provenance restoration.**
|
||||
Alternative: an attribution history stack that restores each char's original
|
||||
author on undo/redo. Why deferred: synchronizing such a stack with VS Code's
|
||||
undo (which coalesces edits) is fragile, and the neutral fix already removes the
|
||||
reported misleading marks. Tracked as #40 (P3) so it isn't lost.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal plan-and-execute #33 (intra-emphasis sentinel hardening) via the token-aware HTML-post-process approach recommended in the issue #33 comment, per specs/coauthoring-interactive-review.md §1.7/§9
|
||||
```
|
||||
|
||||
(Backlog: #40 undo provenance (P3); #32 scroll-sync (feature, needs a Solution
|
||||
Design); #35 repo rename (outward, operator timing).)
|
||||
@@ -0,0 +1,104 @@
|
||||
# Session 0035.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-12T08-53 (PST)
|
||||
> End: 2026-06-12T10-58 (PST)
|
||||
> Type: capture (tracked-lite)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Add "Open Cowriting Review Panel" to the context window when you right click on a markdown file or its tab
|
||||
```
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `main`, clean, pushed. Prior session 0034 had shipped the #38 undo fix
|
||||
(PR #39). Recorded next goal there was #33 (intra-emphasis hardening) — this
|
||||
session instead opened as a **capture** session on a new line of UX asks.
|
||||
- Existing command surface (from `package.json`): `cowriting.editSelection`
|
||||
("Ask Claude to Edit Selection", editor-context, selection-gated);
|
||||
`cowriting.showTrackChangesPreview` ("Open Review Preview", palette +
|
||||
`ctrl+alt+r`); `cowriting.pinDiffBaseline` ("Pin Review Baseline to Now"),
|
||||
registered but **palette-suppressed** (`when:false`) with no menu/keybinding —
|
||||
orphaned since #34 deleted its F6 two-pane host.
|
||||
|
||||
## Arc (turn by turn)
|
||||
|
||||
1. **Claim + resolve.** Claimed tracked-lite transcript 0035 (`--type capture`).
|
||||
Resolved app `vscode-cowriting-plugin`, content repo
|
||||
`benstull/vscode-cowriting-plugin-content` (cloned), tracker
|
||||
`benstull/vscode-cowriting-plugin`.
|
||||
2. **Ask 1 → #41.** Right-click on a markdown file/tab → "Open Cowriting Review
|
||||
Panel". Sized **type/story, P2** (pure menu wiring to the existing preview
|
||||
command). Drafted `issues/review-panel-context-menu.md`. Flagged the label
|
||||
wrinkle (VS Code renders the command's own title). Operator: "first one looks
|
||||
good" → ensured labels (needed host token
|
||||
`wgl-gitea-issues-readwrite-token-git.benstull.org`; bare default 401'd) →
|
||||
filed **#41**.
|
||||
3. **Ask 2 → #42.** "Ask Claude to Edit Document" when right-clicking the body with
|
||||
no selection; Edit Selection/Edit Document on the tab too. Sized
|
||||
**type/feature, P2** — beyond menu wiring it introduces a *new whole-document
|
||||
edit capability* with open product questions (instruction gathering, one big
|
||||
F4 proposal vs many, large-doc/token behavior). Drafted
|
||||
`issues/ask-claude-edit-document-context-menu.md`. Operator: "file as a
|
||||
feature" → filed **#42**.
|
||||
4. **Question: pin baseline.** Operator asked if the baseline can be reset to the
|
||||
current editor. Answer: yes — `cowriting.pinDiffBaseline` does exactly that
|
||||
(`capture(doc,"pinned")` → baseline := `document.getText()`). Initially said
|
||||
"palette-only"; operator's screenshot showed it absent from the palette →
|
||||
re-checked and **corrected**: it's `when:false`-suppressed with no menu/
|
||||
keybinding, i.e. **unreachable from any UI**.
|
||||
5. **Ask 3 → #43 (lead).** Operator's direction: make the **rendered preview the
|
||||
primary interaction surface** — toolbar (home of the annotations checkbox)
|
||||
gains one **adaptive "Ask Claude…" button** (Edit Selection ⇆ Edit Document by
|
||||
*preview* selection) and a **Pin baseline** button. Asked two shaping
|
||||
questions; operator chose **coexist** (native menus #41/#42 become **gateways
|
||||
that open the preview**) and **P1**. Sized **type/feature, P1**; central design
|
||||
risk = mapping a rendered-preview selection back to a source markdown range
|
||||
(fallback: document-level button first). Drafted
|
||||
`issues/preview-toolbar-interaction-surface.md` → operator "file as-is" →
|
||||
filed **#43**.
|
||||
6. **Dependencies.** Operator: make #41/#42 dependent on #43. Set via Gitea
|
||||
issue-dependencies API — discovered the body needs `{"owner","repo","index"}`
|
||||
with the repo key **`repo`** (documented `name` 404s as
|
||||
`IsErrRepoNotExist`). #41 and #42 now **blocked-by #43**; verified.
|
||||
7. **Finalize** (tracked-lite): base survey (session repo clean, nothing to land);
|
||||
no spec/plan artifact (INV-6); memory updated; transcript published.
|
||||
|
||||
## Cut state
|
||||
|
||||
- **Filed:** #41 (story P2), #42 (feature P2), #43 (feature P1) on
|
||||
`benstull/vscode-cowriting-plugin`. #41 & #42 **blocked-by #43**.
|
||||
- **Session repo:** `main`, clean, unchanged (no code touched this session).
|
||||
- **Content repo:** 3 new `issues/*.md` drafts left **uncommitted** (Author's to
|
||||
publish/discard — INV-8). No commits/pushes made to it.
|
||||
|
||||
## Operator plate (loose ends)
|
||||
|
||||
- The pin-baseline UI-reachability gap is captured only *inside* #43's scope
|
||||
(operator chose to fold it in, not file a standalone bug).
|
||||
- #41/#42 carry no comment cross-linking #43; the dependency link is recorded in
|
||||
Gitea instead. (Offered to add comments; not requested.)
|
||||
- Pre-existing untracked drafts from earlier capture sessions still sit in the
|
||||
content repo working tree (not this session's concern).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — all sizing/priority/relationship calls were made with the operator in the
|
||||
moment (sizing of #42 as feature, #43 coexist-vs-supersede and P1, folding the
|
||||
pin gap into #43)._
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/wgl-brainstorming design #43 — preview toolbar as the primary interaction surface
|
||||
(adaptive "Ask Claude…" button: Edit Selection ⇆ Edit Document by preview-selection
|
||||
state; Pin baseline button; annotations checkbox stays). Central design problem:
|
||||
map a selection in the rendered (markdown-it) preview back to a source markdown
|
||||
range; fallback is ship document-level Ask-Claude + Pin first, selection-aware
|
||||
second. Coexists with #41/#42 (native right-click entries become gateways that
|
||||
open the preview). Builds on F7/F10 preview + annotations toggle, F3/F4 inner
|
||||
loop, F6 baseline store (gives orphaned cowriting.pinDiffBaseline a UI home).
|
||||
```
|
||||
@@ -0,0 +1,105 @@
|
||||
# Session 0036.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-12T11-01 (PST)
|
||||
> End: 2026-06-12T12-18 (PST)
|
||||
> Type: brainstorming
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/goal next` — resume the next recorded goal. Memory (session 0035) recorded
|
||||
**Next /goal = design #43**: write the Solution Design for Feature #43 (F11) —
|
||||
the preview toolbar as the primary interaction surface (adaptive Ask-Claude +
|
||||
Pin-baseline buttons).
|
||||
|
||||
## Summary
|
||||
|
||||
A brainstorming session that produced and graduated the **F11 Solution Design**
|
||||
for Feature `#43`: making the F10 rendered-review preview's header toolbar the
|
||||
**primary interaction surface**. Beside the existing annotations on/off checkbox,
|
||||
the toolbar gains a **Pin baseline** button and a **single adaptive "Ask Claude…"
|
||||
button** (Edit Selection when text is selected in the preview / Edit Document
|
||||
otherwise), plus a minimal right-click → Open Review Preview gateway.
|
||||
|
||||
The session opened by classifying `/goal next` → brainstorming, claiming session
|
||||
ID **0036** (`--type brainstorming`), and verifying a clean pushed `main`. It then
|
||||
oriented from the #43 issue (read via the Gitea API) and the F10 interactive-review
|
||||
spec (the architecture F11 extends), and dispatched an Explore agent to produce a
|
||||
precise code map of the preview webview, the markdown-it render path, the F4 edit
|
||||
seam, `pinDiffBaseline`, and the #41/#42 menu state.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- `main` clean and pushed; no in-flight sessions (0035 finalized).
|
||||
- F10 (`#29`) shipped: clean editor + rendered preview as the single review
|
||||
surface (annotations on/off; ✓/✗ on F4 proposals). `#31` shipped: proposals
|
||||
rendered inline at their resolved anchor.
|
||||
- `cowriting.pinDiffBaseline` registered but `when:false` (orphaned since `#34`).
|
||||
- Whole-document editing did not exist; "Edit Selection" was an editor
|
||||
context-menu item only.
|
||||
|
||||
## Turn-by-turn arc
|
||||
|
||||
1. **Classify + claim.** `/goal next` → resume "design #43" → brainstorming.
|
||||
Claimed ID 0036; resolved app facts (content repo present; roadmap = this repo;
|
||||
no BDD corpus); confirmed clean baseline.
|
||||
2. **Orient.** Read issue `#43` (Gitea API, P1/feature), the F10 spec
|
||||
(`coauthoring-interactive-review.md`), and prior content-repo specs/plans. Ran
|
||||
an Explore agent → code map: `trackChangesPreview.ts` (webview + annotations
|
||||
toggle round-trip), `trackChangesModel.ts` (markdown-it; `splitBlocksWithRanges`
|
||||
→ `BlockWithRange` char offsets; **no** source-line mapping in rendered HTML),
|
||||
`extension.ts editSelection` + `proposalController.propose` + F4 `applyAgentEdit`
|
||||
seam, `diffViewController.pinDiffBaseline` (`when:false`), and the absence of
|
||||
#41/#42 menu entries.
|
||||
3. **Surface the forks.** Identified the central design risk — mapping a *rendered
|
||||
preview* selection back to a *source markdown range* — and three load-bearing
|
||||
forks. Asked the operator (AskUserQuestion) with recommendations.
|
||||
4. **Forks locked** (all recommended options chosen): (1) **block-level**
|
||||
selection→source mapping via `data-src` block attributes; (2) document edit
|
||||
**diffed into per-hunk F4 proposals**; (3) **#43 lands a minimal right-click
|
||||
gateway**.
|
||||
5. **Present design + write spec.** Presented the consolidated F11 design
|
||||
(INV-35/36/37, message bus, slices, testing). Wrote
|
||||
`docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md`;
|
||||
self-reviewed; verified reused seams exist (`DiffViewController.pin(document)`,
|
||||
`isAuthorable(scheme)`). Committed to `main` (`b2de25f`; rebased over the
|
||||
0036 placeholder push), matching the F9 direct-`design(...)`-commit precedent.
|
||||
6. **Finalize.** Submitted the spec to the content repo `specs/` (graduated,
|
||||
`855acec`); updated memory; published this transcript.
|
||||
|
||||
## Cut state (final)
|
||||
|
||||
- **Spec graduated:** `specs/2026-06-12-f11-preview-toolbar-interaction-surface.md`
|
||||
in `vscode-cowriting-plugin-content` (`855acec`); code-repo working copy at
|
||||
`docs/superpowers/specs/` (`b2de25f` on `main`).
|
||||
- `main` clean and pushed. No open PRs (Gitea host — no `gh` PR flow).
|
||||
- Memory: added `f11-preview-toolbar-spec-graduated.md` + MEMORY.md pointer.
|
||||
|
||||
## Operator plate (decisions & deferrals)
|
||||
|
||||
- **Three forks** locked interactively (spec §6.7) — see Summary.
|
||||
- **Autonomous low-confidence calls (spec §9, all low-risk):** host `showInputBox`
|
||||
for the instruction (keeps LLM/secrets out of the sealed webview); Pin targets
|
||||
the previewed document via `DiffViewController.pin` (not `activeTextEditor`); new
|
||||
`cowriting.editDocument` command for #42 reuse; `cowriting.pinDiffBaseline`
|
||||
unhidden (`when: editorLangId == markdown`).
|
||||
- **Deferred (spec §9):** char-precise sub-block selection mapping; richer #41/#42
|
||||
menu sets; scroll-sync (#32); large-rewrite hunk capping; repo rename (#35).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
The autonomous calls above (spec §9) were surfaced at closeout: instruction-prompt
|
||||
location, Pin target, the new `editDocument` command, and unhiding the pin command.
|
||||
All are low-risk and reversible; none changed the persisted model. No other
|
||||
low-confidence calls.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/goal plan-and-execute #43 (F11), per specs/2026-06-12-f11-preview-toolbar-interaction-surface.md
|
||||
```
|
||||
|
||||
Start with **SLICE-1** (Pin baseline button + unhide `cowriting.pinDiffBaseline`)
|
||||
— the immediate win that homes the orphaned command. Read
|
||||
`f11-preview-toolbar-spec-graduated.md` and the graduated spec's §6/§7 first.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Session 0037.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-12T13-33 (PST)
|
||||
> End: 2026-06-12T14-22 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED.**
|
||||
>
|
||||
> Outcome: **F11 (#43) shipped to main via PR #44.** The rendered review preview's
|
||||
> header toolbar is now the primary interaction surface — Pin baseline + a single
|
||||
> adaptive Ask-Claude button (Edit Selection ⇆ Edit Document) routing through the
|
||||
> existing F4/F6/F3 machinery, block-level selection→source mapping, document
|
||||
> edits diffed into per-hunk proposals, an `editor/title` gateway, and the
|
||||
> unhidden `pinDiffBaseline`. 208 unit + 9/9 F11 host E2E green. Issue #43 closed;
|
||||
> branch deleted; plan archived to the content repo.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
plan-and-execute #43 (F11), per specs/2026-06-12-f11-preview-toolbar-interaction-surface.md. Start with SLICE-1 (Pin baseline button + unhide pinDiffBaseline) — the immediate win.
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
Plan-and-execute **F11 (#43)** — preview toolbar as the primary interaction
|
||||
surface — per `docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md`.
|
||||
Implementation plan archived at `docs/superpowers/plans/2026-06-12-f11-preview-toolbar.md`.
|
||||
Posture: autonomous (yolo). Branch `f11-preview-toolbar`.
|
||||
|
||||
Five slices (spec §7.2), each green (unit + host E2E) before the next:
|
||||
|
||||
- **SLICE-1 — Pin baseline button + reachability** *(this session's lead — the immediate win)*: webview Pin button → `{pinBaseline}` → host `DiffViewController.pin(previewedDoc)`; unhide `cowriting.pinDiffBaseline` (`when: editorLangId == markdown`).
|
||||
- **SLICE-2 — Block-offset emission** (INV-36 data layer): pure `data-src-start/end` on every block, both render modes.
|
||||
- **SLICE-3 — Edit Document + hunk path** (INV-37 doc half): `diffToHunks`, `runEditAndPropose`, `cowriting.editDocument`.
|
||||
- **SLICE-4 — Adaptive Edit Selection** (INV-37 selection half): `selectionchange` label flip + selection→`data-src` resolution → single-range propose.
|
||||
- **SLICE-5 — Gateway, edges, tests & docs**: `editor/title` gateway, non-authorable disabling, MANUAL-SMOKE-F11.md, README.
|
||||
|
||||
## Session arc
|
||||
|
||||
Pre-state: clean `main` at `b2de25f` (after a fast-forward of 2; the F11 spec had
|
||||
just graduated in session 0036). #43 = `type/feature` with a graduated Solution
|
||||
Design → eligible planning-and-executing anchor (R2/R3).
|
||||
|
||||
1. **Claim + setup.** Session gate → `wgl-planning-and-executing`; claimed ID
|
||||
**0037** (no concurrent sessions). Read the F11 spec (found at
|
||||
`docs/superpowers/specs/`, not `specs/`). Wrote the implementation plan
|
||||
(`docs/superpowers/plans/2026-06-12-f11-preview-toolbar.md`), branched
|
||||
`f11-preview-toolbar`. Baseline 197 unit green.
|
||||
2. **SLICE-1 (commit 8b9e61a).** TDD: failing E2E (`receiveMessage` seam absent) →
|
||||
extracted `handleWebviewMessage`, added the `pinBaseline` intent →
|
||||
`DiffViewController.pin(previewedDoc)`, the Pin button, theme CSS, unhid
|
||||
`pinDiffBaseline`. Discovered the `undoMarks` E2E flakes (passed on re-run).
|
||||
3. **SLICE-2 (1ef9451).** Pure `srcAttr` helper threaded through renderOp /
|
||||
renderReviewOp / renderReview; `renderPlain` switched to per-block bare divs.
|
||||
data-src on every live block in both modes; removed/proposal blocks carry none.
|
||||
4. **SLICE-3 (0d1a563).** Pure `diffToHunks` (word-level, coalescing); host
|
||||
`runEditAndPropose` + injectable `editTurn`/`setEditTurnForTest`; the askClaude
|
||||
message + `cowriting.editDocument`; Edit Document button.
|
||||
5. **SLICE-4 (03b61ed).** Webview adaptive label (`selectionchange`) +
|
||||
`nearestSrc`/`selectionSrcRange` block-union mapping; host range branch
|
||||
(already shared from SLICE-3). E2E for the selection path.
|
||||
6. **SLICE-5 (1564ef5).** `editor/title` gateway; `authorable` render flag +
|
||||
`editControlsEnabled` seam + webview disable; `MANUAL-SMOKE-F11.md`; README.
|
||||
7. **Self code review (subagent) → fixes (47cc733).** Caught a **Critical**:
|
||||
pure-insertion hunks were born-orphaned (verified against `anchorer.resolve`).
|
||||
Fixed with `anchorInsertion`; added reconstruct + accept-all E2E coverage;
|
||||
added `turnId`; documented the renderPlain cross-block tradeoff + a
|
||||
characterization test. Ran the isolation experiment proving the `undoMarks`
|
||||
flake is F11-independent.
|
||||
8. **Ship.** Pushed; PR **#44** (Gitea API) → merged to `main` (62a2229); #43
|
||||
commented + closed; branch deleted; 208 unit green on main.
|
||||
|
||||
Cut state: on `main`, clean, fully pushed. The only red in the host E2E suite is
|
||||
the pre-existing `undoMarks` flake (see Deferred decisions); all 9 F11 E2E + 208
|
||||
unit are green.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal plan-and-execute #42 then #41 (now unblocked by F11) — expand the right-click menu sets into the preview (Ask Claude to Edit + Open Review Panel), per spec §6.7 fork 3; first capture the pre-existing undoMarks E2E flake as a follow-up bug.
|
||||
```
|
||||
|
||||
## 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._
|
||||
|
||||
- **Pre-existing E2E flake — PROVEN F11-independent, recommend a follow-up issue:**
|
||||
`undoMarks.test.ts` ("F10 #38 — undo of a deletion of baseline text leaves it
|
||||
unattributed") fails on `executeCommand("undo")` not reverting the
|
||||
`applyEdit`-applied changes. Early in the session it failed ~1/3 (passing on
|
||||
re-run); later in the session it failed consistently (machine-load sensitive).
|
||||
**Isolation experiment:** removed all F11 tests, clean rebuild → the undo test
|
||||
still fails identically (44 passing, same `undo restored 'bravo'` at the same
|
||||
line). So it is NOT caused/aggravated by F11 — F11 touches only preview message
|
||||
routing + the pure render layer. Tried a focus+single-undo+poll hardening; it
|
||||
did NOT fix this run (root cause is `undo` not reverting programmatic
|
||||
`WorkspaceEdit`s in test-electron, not mere timing), so I reverted it to keep
|
||||
the F11 PR scoped. **Recommend a dedicated follow-up issue:** redesign the undo
|
||||
test to not depend on `executeCommand("undo")` over `applyEdit` (e.g. drive the
|
||||
attribution branch directly, or type real edits). All 9 F11 E2E tests are green.
|
||||
- **SLICE-2 off-mode wrapping (autonomous call):** `renderPlain` now wraps each
|
||||
block in a bare `<div data-src-start/end>` (no `cw-` class) rather than
|
||||
rendering the whole document in one markdown pass. This makes the off/clean
|
||||
preview a selection→source surface (INV-36) and matches `renderReview`'s
|
||||
long-standing per-block rendering. Trade-off (raised in code review): cross-block
|
||||
markdown constructs separated by blank lines (reference-link defs, footnotes)
|
||||
don't resolve in off-mode — a regression for off-mode specifically, but it makes
|
||||
both modes consistent (on-mode already had it). Inherent to the operator-locked
|
||||
block-level mapping (§6.7). Kept per INV-36 ("both modes"); documented with a
|
||||
docstring note + a characterization test. A cross-block-fidelity improvement
|
||||
(markdown-it source maps) is a possible follow-up, not a change to the locked
|
||||
decision.
|
||||
- **Code review run (self, via subagent) before merge:** found 1 Critical (pure-
|
||||
insertion hunks born-orphaned — could never be accepted), 2 Important (the
|
||||
cross-block renderPlain regression above; sequential multi-hunk accept untested),
|
||||
3 Minor (no turnId; test-seam recompute; webview disable not auto-tested). Fixed
|
||||
the Critical (anchorInsertion + reconstruct/accept-all tests), the turnId, and
|
||||
added accept-all + characterization coverage; accepted/documented the cross-block
|
||||
tradeoff. Commit `47cc733`.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Session 0038.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-12T15-55 (PST)
|
||||
> End: 2026-06-12T16-06 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Pre-state
|
||||
|
||||
`main` clean and pushed (HEAD `21df670`, behind origin by 1 — fast-forwarded to
|
||||
`de53305` the session-claim commit). No in-flight sessions. Open backlog: #42
|
||||
(feature, Ask Claude to Edit Document), #41 (story, right-click → Open Review
|
||||
Panel); both filed in capture session 0035 as F11 follow-ups.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/goal next` → stored goal "plan-and-execute #42 (Ask Claude to Edit Document),
|
||||
then #41" (from session 0037 finalize).
|
||||
|
||||
## Plan
|
||||
|
||||
**Gate decision (§4.3 R3):** #42 is `type/feature` with no Solution Design — an
|
||||
ineligible anchor for plan-and-execute. Operator chose: **plan-and-execute #41
|
||||
now (eligible story); brainstorm #42's design next.** So this session does #41
|
||||
only; #42's design becomes the next goal.
|
||||
|
||||
**#41 — Open Cowriting Review Panel from the markdown file/tab right-click menu**
|
||||
(story, P2):
|
||||
- `explorer/context` menu item for `.md` → `cowriting.showTrackChangesPreview`.
|
||||
- `editor/title/context` menu item for markdown tabs → same command.
|
||||
- Command accepts the right-clicked resource URI (open clicked doc, not just
|
||||
active editor).
|
||||
- Menu label reads "Open Cowriting Review Panel" (least-churn option).
|
||||
- E2E coverage: menu contributed + opens preview.
|
||||
|
||||
### Outcome
|
||||
|
||||
**#41 SHIPPED** to main (PR #45, merged + branch deleted; issue auto-closed).
|
||||
- `explorer/context` + `editor/title/context` menus (markdown-gated) →
|
||||
`cowriting.showTrackChangesPreview`; command retitled "Open Cowriting Review
|
||||
Panel"; command resolves the *clicked* doc (opens it if not already open),
|
||||
no-arg path unchanged.
|
||||
- 208 unit + 7 new E2E (`reviewPanelMenu.test.ts`); full host E2E suite green
|
||||
(66 passing across both workspaces); typecheck clean.
|
||||
|
||||
**#42 NOT done this session** — `type/feature` with no Solution Design is an
|
||||
ineligible plan-and-execute anchor (§4.3 R3). Operator chose: brainstorm #42's
|
||||
design next. → **Next /goal:** brainstorm #42 (Ask Claude to Edit Document)
|
||||
Solution Design, then plan-and-execute #42.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- #42 gate (R3): asked the operator rather than auto-overriding; they chose
|
||||
"#41 now, brainstorm #42 next." Not low-confidence — recorded for the
|
||||
finalize report.
|
||||
- Pre-existing F11-independent `undoMarks` E2E flake noted in session 0037 still
|
||||
uncaptured as a follow-up issue (out of scope for #41).
|
||||
|
||||
## Session arc
|
||||
|
||||
1. Claimed session 0038 (peek → no in-flight → atomic push). Fast-forwarded
|
||||
`main`. Fetched #42 + #41 from the Gitea API.
|
||||
2. Ran the §4.3 issue-type gate: #42 (feature, no design) ineligible for
|
||||
plan-and-execute; #41 (story) eligible. Surfaced the fork; operator chose
|
||||
"#41 now, brainstorm #42 next."
|
||||
3. Implemented #41: read `trackChangesPreview.ts` (the `showTrackChangesPreview`
|
||||
command already took an optional `uri` but only searched *open* docs) +
|
||||
`package.json` menus. Fixed the command to open the clicked doc; added
|
||||
`explorer/context` + `editor/title/context`; retitled the command; updated
|
||||
README.
|
||||
4. Wrote `reviewPanelMenu.test.ts` (7 E2E). Typecheck + 208 unit + full host
|
||||
E2E green.
|
||||
5. Branch → commit → push → PR #45 → merged (branch deleted); #41 auto-closed.
|
||||
6. Finalized: memory updated, transcript published.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/goal brainstorm #42 (Ask Claude to Edit Document) Solution Design, then plan-and-execute #42
|
||||
```
|
||||
|
||||
**Next /goal:** brainstorm #42 (Ask Claude to Edit Document) Solution Design,
|
||||
then plan-and-execute #42. Design forks to settle: whole-doc instruction
|
||||
gathering; one large proposal vs several; large-document token-limit behavior.
|
||||
`cowriting.editDocument` + `runEditAndPropose(doc, {kind:"document"})` already
|
||||
exist (F11 prep) — #42 is mostly menu/tab wiring + those decisions on top.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Session 0039.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-12T16-07 (PST)
|
||||
> End: 2026-06-12T17-02 (PST)
|
||||
> Type: capture (tracked-lite)
|
||||
> Posture: careful (capture default)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture We need a way to accept all of Claude's edits`
|
||||
|
||||
## Pre-state
|
||||
|
||||
Opened immediately after finalizing session 0038. `main` clean + pushed.
|
||||
Backlog open: #42 (feature, Ask Claude to Edit Document). No existing
|
||||
accept-all draft in the content repo.
|
||||
|
||||
## What was captured
|
||||
|
||||
**One ask → one issue filed.**
|
||||
|
||||
**#46** — `type/feature`, `priority/P2` —
|
||||
"Accept all of Claude's pending proposals on the current document in one gesture."
|
||||
https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/46
|
||||
|
||||
Confirmed gap from the code: accept is **per-proposal only** today
|
||||
(`cowriting.acceptProposal` → `ProposalController.acceptById`; F10/F11 preview ✓
|
||||
per `cw-proposal` block). No bulk accept. Felt most after F11 "Edit Document"
|
||||
(#42), where one whole-document rewrite fans out into N per-hunk proposals.
|
||||
|
||||
## Decisions (capture forks)
|
||||
|
||||
- **Scope of "all" = all pending proposals on the current document** (operator
|
||||
choice) — not per-turn, not cross-document. Safe apply-order; orphaned /
|
||||
unresolved proposals **skipped + reported**, never force-applied.
|
||||
- **Type = `type/feature`** (operator choice). The load-bearing fork is
|
||||
apply-order + re-anchoring: each accept mutates the buffer and re-resolves the
|
||||
remaining proposals' anchors, so bulk-accept needs a correct order /
|
||||
re-resolve-between strategy. Design step also settles confirmation y/n, button
|
||||
placement (preview header vs F11 toolbar), and applied-vs-skipped surfacing.
|
||||
- Non-goals recorded: no reject-all, no cross-document/workspace accept-all, no
|
||||
per-turn scope, no new accept seam (reuse F4 `acceptById` in a loop; webview
|
||||
posts an `acceptAll` intent — sealed-webview INV-21/35).
|
||||
|
||||
## Session arc
|
||||
|
||||
1. Claimed tracked-lite session 0039 (`--type capture`); fast-forwarded `main`.
|
||||
2. Resolved the content repo (`vscode-cowriting-plugin-content`); cloned + listed
|
||||
existing drafts (no accept-all duplicate).
|
||||
3. Inspected the code's accept surface → confirmed per-proposal-only, no bulk.
|
||||
4. Reflected the ask; asked two scope/sizing questions → current-doc + feature.
|
||||
5. Drafted `issues/accept-all-proposals.md` (full §5 feature template).
|
||||
6. Operator approved → ensured labels → filed **#46**.
|
||||
7. Finalized: memory updated, transcript published. Draft left uncommitted in the
|
||||
content-repo working tree (INV-8 — Author to publish/discard).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- Capture scripts 401 with the default token service; needed
|
||||
`WGL_CAPTURE_TOKEN_SERVICE=wgl-gitea-issues-readwrite-token-git.benstull.org`
|
||||
(host-specific). Worth a plugin-feedback follow-up if it recurs.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
Unchanged from 0038 — the next coding goal stands:
|
||||
|
||||
```
|
||||
/goal brainstorm #42 (Ask Claude to Edit Document) Solution Design, then plan-and-execute #42
|
||||
```
|
||||
|
||||
#46 (accept-all) is now in the backlog alongside #42; either could be the next
|
||||
brainstorming target.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Session 0040.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-12T17-03 (PST)
|
||||
> End: 2026-06-12T17-21 (PST)
|
||||
> Type: capture (tracked-lite)
|
||||
> Posture: careful (capture default)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture When Claude suggests changes, a single change should be for a
|
||||
paragraph (or header, or bulletpoint), not individual words. It's too much to
|
||||
review`
|
||||
|
||||
## Pre-state
|
||||
|
||||
Third session of the run (after 0038 ship + 0039 capture). `main` clean +
|
||||
pushed. Backlog: #42 (Edit Document), #46 (accept-all, filed 0039).
|
||||
|
||||
## What was captured
|
||||
|
||||
**One ask → one issue filed.**
|
||||
|
||||
**#47** — `type/feature`, `priority/P1` —
|
||||
"Block-granularity proposals: one change per paragraph/header/bullet, not per word."
|
||||
https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/47
|
||||
|
||||
Confirmed from code: F11 **Ask Claude to Edit Document** mints proposals at **word
|
||||
granularity** — `diffToHunks` (src/trackChangesModel.ts) uses `diffWordsWithSpace`
|
||||
and flushes a hunk on every unchanged token, so each changed word is its own ✓/✗
|
||||
proposal (INV-37 "per-hunk"). A light copy-edit pass explodes into dozens of tiny
|
||||
proposals → "too much to review."
|
||||
|
||||
## Decisions (capture forks)
|
||||
|
||||
- **Scope = document edits only** (operator choice) — the `diffToHunks` fan-out.
|
||||
Selection edits are already a single proposal, so untouched.
|
||||
- **Type = `type/feature`** (operator choice). Supersedes F11 **INV-37 per-hunk →
|
||||
per-block**. Real forks recorded for the design step: block taxonomy (list items
|
||||
vs whole lists, tables, blockquotes, nesting); anchoring inserted blocks (a
|
||||
block-level analogue of today's word-oriented `anchorInsertion`); and the
|
||||
**attribution tradeoff** — accepting a block attributes the *whole* block to
|
||||
Claude, including unchanged words.
|
||||
- Reuse the existing block splitter (`diffBlocks`; code/mermaid fences atomic,
|
||||
INV-23). Intra-block `<ins>`/`<del>` **rendering is unchanged** — only the
|
||||
decision *unit* becomes the block. `priority/P1` (directly removes the stated
|
||||
pain).
|
||||
|
||||
## Session arc
|
||||
|
||||
1. Claimed tracked-lite session 0040 (`--type capture`); fast-forwarded `main`.
|
||||
2. Inspected `diffToHunks` → confirmed word-level granularity is the cause.
|
||||
3. Reflected the ask; asked scope + sizing → document-edits + feature.
|
||||
4. Drafted `issues/block-granularity-proposals.md` (full §5 feature template).
|
||||
5. Operator approved → filed **#47** (labels already ensured this run).
|
||||
6. Finalized: memory updated, transcript published. Draft left uncommitted in the
|
||||
content-repo working tree (INV-8).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None._
|
||||
|
||||
## Backlog observation
|
||||
|
||||
#42 (Ask Claude to Edit Document), #46 (accept-all), and #47 (block granularity)
|
||||
form one **edit-flow cluster** around the Ask-Claude / document-edit loop. They
|
||||
interact (block granularity changes how many proposals exist; accept-all changes
|
||||
how they're taken; #42 is the entry point that produces them) and are best
|
||||
**brainstormed / sequenced together** rather than designed in isolation.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
The standing coding goal is unchanged; consider folding the cluster in:
|
||||
|
||||
```
|
||||
/goal brainstorm the Ask-Claude document-edit cluster (#42 Edit Document, #47 block-granularity proposals, #46 accept-all) and sequence/design it, starting with #42
|
||||
```
|
||||
|
||||
(Or keep the narrower `/goal brainstorm #42 … then plan-and-execute #42` and take
|
||||
#47/#46 after.)
|
||||
@@ -0,0 +1,83 @@
|
||||
# Session 0041.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-12T18-17 (PST) · End: 2026-06-13T07-16 (PST)
|
||||
> Type: brainstorming
|
||||
> Posture: autonomous (yolo)
|
||||
> Goal: brainstorm the Ask-Claude document-edit cluster (#42, #47, #46) and
|
||||
> sequence/design it, starting with #42
|
||||
> Outcome: **GRADUATED** — one combined Solution Design
|
||||
> `specs/coauthoring-document-edit-flow.md` (content repo, `status: graduated`,
|
||||
> commit `9d016e8`), sequencing #42 → #47 → #46.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/goal brainstorm the Ask-Claude document-edit cluster (#42, #47, #46) and
|
||||
sequence/design it, starting with #42`
|
||||
|
||||
## Plan
|
||||
|
||||
Brainstorming session — produce a Solution Design for the **Ask-Claude
|
||||
document-edit flow**, the cluster of #42 (Ask Claude to Edit Document — entry
|
||||
points), #47 (block-granularity proposals), #46 (accept-all). Deliverable: one
|
||||
combined Solution Design (content repo `specs/`), sequencing the three as a
|
||||
delivery plan **#42 (reach) → #47 (review) → #46 (accept)**.
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Init.** Claimed session 0041 (`--type brainstorming`), clean pushed `main`
|
||||
baseline, seeded the Plan block. Resolved app repos (roadmap = framework repo;
|
||||
content = `vscode-cowriting-plugin-content`; no BDD corpus).
|
||||
2. **Read the cluster.** Read the three capture drafts (#42/#47/#46) in the
|
||||
content repo `issues/`. Then ground in code: `package.json` menus/commands,
|
||||
`trackChangesModel.ts` (`splitBlocks`/`diffBlocks`/`diffToHunks`/
|
||||
`anchorInsertion`/`renderReview`), `trackChangesPreview.ts`
|
||||
(`editDocument`/`askClaude`/`runEditAndPropose`), `proposalController.ts`
|
||||
(`acceptById`/`accept`→`applyAgentEdit`/`listProposals`).
|
||||
3. **Key reframe.** F11 (session 0037) **already shipped** the document-edit path
|
||||
— `cowriting.editDocument` → `runEditAndPropose({kind:"document"})` →
|
||||
`diffToHunks` (word-level, INV-37). So #42 is mostly DONE (remaining = menu
|
||||
wiring); #47 swaps the document-branch diff to block-level; #46 loops the
|
||||
existing accept seam.
|
||||
4. **Found a spec-hygiene gap.** The F11 (#43) Solution Design was never graduated
|
||||
to `specs/` — INV-35/36/37 live only in code comments + the F11 issue draft.
|
||||
Logged as OQ-2 (follow-up), restated the depended-on F11 invariants in the new
|
||||
spec.
|
||||
5. **Locked two forks with the operator** (AskUserQuestion):
|
||||
- **D-1** — per-block proposals **preserve unchanged-span attribution** (block =
|
||||
decision unit, word = attribution unit; intra-block `diffToHunks` sub-diff at
|
||||
accept; INV-40). *Not* whole-block→Claude.
|
||||
- **D-2** — ship as **one combined Solution Design**, not three.
|
||||
6. **Wrote the spec.** `specs/coauthoring-document-edit-flow.md` — business →
|
||||
product → engineering, new invariants INV-38..42 (INV-39 supersedes INV-37),
|
||||
delivery plan SLICE-1/2/3 = #42/#47/#46, traceability, decisions log. Self-
|
||||
reviewed (no placeholders, consistent, scoped). Did **not** invoke
|
||||
`writing-plans` (wgl-brainstorming override — the artifact is the spec,
|
||||
submitted at finalize; implementation is a downstream session).
|
||||
7. **Finalize.** Submitted the spec `--status graduated`; memory updated.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **D-3 (low-confidence driver call)** — sequenced **#42 → #47 → #46** per the
|
||||
goal's "starting with #42," **despite #47 carrying the P1 value** ("too much to
|
||||
review" is the sharpest pain). The three are code-independent so the order is
|
||||
reversible (§7.1); #47 and #42 could swap with no rework if the operator wants
|
||||
the P1 review fix to land first. Surfaced at closeout; logged as D-3 in the
|
||||
spec §9.
|
||||
- D-4 (driver) block taxonomy = exactly `splitBlocks`'s units; D-5 (driver)
|
||||
accept-all needs no confirmation, button shown ≥2 pending. Both in spec §9.
|
||||
|
||||
## Loose ends → next session
|
||||
|
||||
- **OQ-2** — graduate the F11 (#43) spec to `specs/` for a clean invariant ledger
|
||||
(INV-35/36/37 currently code-only).
|
||||
- **OQ-1** — whether selection edits should also preserve-unchanged-span
|
||||
attribution (out of #47 scope).
|
||||
- Content-repo local working tree carries pre-existing untracked drafts/specs
|
||||
(prior sessions / the Author's) — not landed by this session.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal plan-and-execute #42 (SLICE-1 of the document-edit flow — Ask-Claude entry-point menu wiring), per specs/coauthoring-document-edit-flow.md
|
||||
```
|
||||
@@ -0,0 +1,62 @@
|
||||
# Session 0042.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-12T21-04 (PST) · End: 2026-06-13T07-16 (PST)
|
||||
> Type: capture (tracked-lite)
|
||||
> Posture: autonomous (yolo)
|
||||
> Goal: capture "When I pin the baseline, there should be no annotations in the
|
||||
> review panel"
|
||||
> Outcome: filed **#48** (`type/story`, `priority/P2`).
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture When I pin the baseline, there should be no annotations in the
|
||||
review panel`
|
||||
|
||||
Opened mid-session, concurrent with the in-flight brainstorming session 0041
|
||||
(only that — my own — session in flight; warned + proceeded per autonomous).
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Claim.** Claimed session 0042 (`--type capture`, tracked-lite). App +
|
||||
content repo already resolved from 0041.
|
||||
2. **Ground the behavior.** Read `renderReview`/`colorByAuthor`/`renderReviewOp`
|
||||
in `trackChangesModel.ts`: an **unchanged** block renders as
|
||||
`cw-blk cw-unchanged` with `colorByAuthor` applied (line ~526). So after a Pin
|
||||
baseline (baseline == current → every block unchanged), the change axis is
|
||||
empty but **F3 authorship coloring still paints every block** — and the panel
|
||||
*does* refresh on pin (`onDidChangeBaseline`), so it's not a stale render. The
|
||||
author colors are the "annotations."
|
||||
3. **Scope fork (AskUserQuestion).** Operator chose **pin → fully clean panel**
|
||||
(zero-diff state: change marks AND authorship colors gone), **not** the broader
|
||||
"authorship never colors unchanged text." → a single, design-free increment.
|
||||
4. **Type.** Would be `type/bug`, but `capture-issues.sh` accepts only
|
||||
epic|feature|story|task (the gap plugin feedback #124 tracks — not re-filed).
|
||||
Filed as the accurate fileable type `type/story`, P2.
|
||||
5. **Draft + approval + file.** Authored
|
||||
`issues/pin-baseline-clears-review-annotations.md`; operator **approved**;
|
||||
ensured labels (`--host git.benstull.org`,
|
||||
`WGL_CAPTURE_TOKEN_SERVICE=wgl-gitea-issues-readwrite-token-git.benstull.org`);
|
||||
filed **#48** —
|
||||
https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/48
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Type compromise (driver, tooling-forced)** — filed as `type/story` though the
|
||||
observation is bug-flavored (surprising behavior on a primary gesture);
|
||||
`capture-issues.sh` can't type `bug`. The bug-typing gap is already tracked as
|
||||
plugin feedback **#124**; not re-filed.
|
||||
|
||||
## Loose ends
|
||||
|
||||
- Draft `issues/pin-baseline-clears-review-annotations.md` left **uncommitted** in
|
||||
the content repo working tree (the Author's to publish or discard — capture
|
||||
INV-8).
|
||||
|
||||
## Next /goal
|
||||
|
||||
(Continuation is the 0041 handoff.)
|
||||
|
||||
```
|
||||
/goal plan-and-execute #42 (SLICE-1 of the document-edit flow — Ask-Claude entry-point menu wiring), per specs/coauthoring-document-edit-flow.md
|
||||
```
|
||||
@@ -0,0 +1,92 @@
|
||||
# Session 0043.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T07-18 (PST)
|
||||
> Type: planning-and-executing
|
||||
> End: 2026-06-13T07-38 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
> Posture: autonomous (yolo)
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/goal plan-and-execute #42 (SLICE-1 of the document-edit flow — Ask-Claude
|
||||
entry-point menu wiring), per specs/coauthoring-document-edit-flow.md`
|
||||
|
||||
## Plan
|
||||
|
||||
**SLICE-1 — #42 (reach)** of the document-edit flow Solution Design
|
||||
(`specs/coauthoring-document-edit-flow.md`, §7.2). Anchor: Feature #42
|
||||
(`type/feature`); design graduated (session 0041) → §4.3 R3 satisfied.
|
||||
|
||||
Goal (INV-38): "Ask Claude to Edit" reachable from the editor **body** and the
|
||||
editor **tab**, selection-aware (selection → `editSelection`; no selection →
|
||||
`editDocument`), both gated to markdown/authorable docs, both routing through the
|
||||
single `runEditAndPropose` path. Smallest increment — command already exists.
|
||||
|
||||
Tasks:
|
||||
1. `package.json` — add `editSelection` + `editDocument` to `editor/context`
|
||||
(selection-aware `when`, markdown+authorable) and `editor/title/context`
|
||||
(selection-aware, `resourceLangId == markdown`); keep titles.
|
||||
2. `trackChangesPreview.ts` — `cowriting.editDocument` accepts an optional tab
|
||||
`uri` arg (mirror `showTrackChangesPreview`'s #41 resolution): open/resolve the
|
||||
clicked doc, else fall back to the active editor.
|
||||
3. E2E (`test/e2e/suite/`) — menu entries present + selection-aware + markdown-gated;
|
||||
`editDocument(uri)` resolves the tab doc and produces a document-scoped proposal.
|
||||
|
||||
No model change, no new persisted artifact. No deploy pipeline (VS Code extension).
|
||||
|
||||
## Results
|
||||
|
||||
**SLICE-1 (#42, reach) shipped to `main`** — PR
|
||||
[#49](https://git.benstull.org/benstull/vscode-cowriting-plugin/pulls/49)
|
||||
(merged), issue #42 closed.
|
||||
|
||||
- `package.json` — `editSelection` + `editDocument` added to `editor/context`
|
||||
(selection-aware, `editorLangId == markdown` + `file`/`untitled`) and
|
||||
`editor/title/context` (selection-aware, `resourceLangId == markdown`);
|
||||
existing `editor/context` `editSelection` entry markdown-gated to match INV-38.
|
||||
- `src/trackChangesPreview.ts` — `cowriting.editDocument` now accepts the clicked
|
||||
tab's resource `Uri` (opens it if needed), falling back to the active editor;
|
||||
mirrors `showTrackChangesPreview`'s #41 resolution.
|
||||
- `test/e2e/suite/f12Reach.test.ts` — 4 new host E2E (menu wiring declarative +
|
||||
tab-URI targeting + no-arg fallback).
|
||||
- `docs/MANUAL-SMOKE-F12.md` — SLICE-1 reach smoke steps.
|
||||
|
||||
Verification: `tsc --noEmit` clean; **208 unit** green; **65/5 host E2E** green
|
||||
(main suite up from 61 → 65 with the 4 new tests).
|
||||
|
||||
**Next:** SLICE-2 — #47 (review, **P1**): document edits propose per changed
|
||||
block (`diffToBlockHunks`, INV-39 supersedes INV-37; word-precise intra-block
|
||||
attribution INV-40; block-insertion anchoring INV-41). Then SLICE-3 — #46
|
||||
(accept-all, INV-42).
|
||||
|
||||
## Session arc
|
||||
|
||||
1. **Gate + claim.** Classified the `/goal` as planning-and-executing; claimed
|
||||
session 0043 (peek showed nothing in flight). Baseline: local `main` was 4
|
||||
behind `origin/main` → fast-forwarded clean.
|
||||
2. **Anchor gate (§4.3 R3).** #42 is `type/feature`; its design is the graduated
|
||||
combined Solution Design `specs/coauthoring-document-edit-flow.md` (session
|
||||
0041) → R3 satisfied, proceed.
|
||||
3. **Read the spec**, scoped SLICE-1 (reach). Explored the code: `package.json`
|
||||
menus, `editDocument`/`editSelection` handlers, the F11 E2E harness (`setEditTurnForTest`).
|
||||
4. **TDD.** Wrote `f12Reach.test.ts` first (red), then wired the menus + tab-URI
|
||||
resolution (green).
|
||||
5. **Verified** (tsc clean, 208 unit, 65/5 E2E), self-reviewed the diff, shipped
|
||||
via PR #49 (merged), closed #42, updated memory, checkpoint-published the
|
||||
transcript.
|
||||
|
||||
## 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._
|
||||
|
||||
- **Markdown-gating `editSelection`'s `editor/context` entry** (driver call,
|
||||
low-confidence). Spec §5/INV-38 say both Ask-Claude entries are markdown-gated,
|
||||
but F8 made the `editSelection` *command* work on any authorable doc and its
|
||||
current right-click entry has no `editorLangId == markdown` gate. Followed the
|
||||
spec: gated the **menu** entries to markdown while leaving the command handlers'
|
||||
behavior intact (palette still works on any authorable file). Removes the
|
||||
body right-click Ask-Claude-Edit-Selection on non-markdown files — acceptable
|
||||
since the proposal review surface (F10 preview) is markdown-only.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Session 0044.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T07-40 (PST)
|
||||
> Type: planning-and-executing
|
||||
> End: 2026-06-13T08-04 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
> Posture: autonomous (yolo)
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
/goal plan-and-execute #47 (SLICE-2 of the document-edit flow — document edits propose per changed block: diffToBlockHunks INV-39 supersedes INV-37, word-precise intra-block attribution INV-40, block-insertion anchoring INV-41), per specs/coauthoring-document-edit-flow.md
|
||||
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
**SLICE-2 — #47 (review, P1)** of the document-edit flow
|
||||
(`specs/coauthoring-document-edit-flow.md` §7.2). Anchor: Feature #47
|
||||
(`type/feature`); covered by the graduated combined Solution Design → §4.3 R3.
|
||||
|
||||
Document rewrites propose **one F4 proposal per changed block** (the unit a human
|
||||
reviews), but each accept reconciles attribution at **word** granularity (the unit
|
||||
F3 records). INV-39 supersedes INV-37 for document edits; INV-40 word-precise
|
||||
intra-block attribution; INV-41 block-insertion anchoring. Selection edits
|
||||
unchanged.
|
||||
|
||||
Tasks:
|
||||
1. `model.ts` — add optional `granularity?: "block" | "single"` to `Proposal`
|
||||
(additive, back-compat: absent ⇒ `"single"`). INV-39/§6.3.
|
||||
2. `proposalModel.ts` — `addProposal` opts gains `granularity`, threaded onto the
|
||||
proposal (omitted when absent).
|
||||
3. `trackChangesModel.ts` — new pure `diffToBlockHunks(currentText,
|
||||
rewrittenText): EditHunk[]`: split both sides into the existing block units
|
||||
(`splitBlocksWithRanges` for source ranges + `splitBlocks` keys), diff blocks
|
||||
(reuse `diffArrays` keying like `diffBlocks`), emit ONE `EditHunk` per
|
||||
changed/added/removed block — changed → `[block.start,end)`→rewrite; fences
|
||||
atomic (whole-fence hunk); inserted → anchored to adjacent boundary (INV-41,
|
||||
reuse the `anchorInsertion` idea); unchanged → none. Same `EditHunk` shape as
|
||||
`diffToHunks` (which is RETAINED as the intra-block sub-diff engine).
|
||||
4. `trackChangesPreview.ts` — `runEditAndPropose` **document branch** uses
|
||||
`diffToBlockHunks` and tags each minted proposal `granularity:"block"`.
|
||||
5. `proposalController.ts` — `accept`: when `granularity === "block"`, re-resolve
|
||||
the block text, `diffToHunks(blockText, replacement)` → per-run word sub-diff,
|
||||
apply **one `applyAgentEdit` per changed run, descending offset** so only the
|
||||
runs Claude changed land Claude-attributed; unchanged spans keep prior author
|
||||
(INV-40). Non-block keeps the single `applyAgentEdit`.
|
||||
6. Tests: unit (`diffToBlockHunks` + INV-40 controller) + host E2E (#47 cases in
|
||||
§6.8); update the existing f11 document-path E2E (now per-block, supersedes
|
||||
INV-37 word-level expectation); `docs/MANUAL-SMOKE-F12.md` §2.
|
||||
|
||||
**Design note (seam constraint):** `pendingEdits.matchEvent` resolves ONE
|
||||
registration per change event, so INV-40's per-run attribution is implemented as
|
||||
**sequential** `applyAgentEdit` calls (descending offset), not a single
|
||||
multi-replace `WorkspaceEdit`. The spec's "one undo-grouped edit" wording is thus
|
||||
approximated as N undo steps per block accept — see Deferred decisions.
|
||||
|
||||
No new persisted artifact; no deploy pipeline (VS Code extension).
|
||||
|
||||
## Results
|
||||
|
||||
**SLICE-2 (#47, review, P1) shipped to `main`** — PR
|
||||
[#50](https://git.benstull.org/benstull/vscode-cowriting-plugin/pulls/50)
|
||||
(merged), issue #47 closed.
|
||||
|
||||
- `trackChangesModel.ts` — new pure `diffToBlockHunks` (block-key alignment via
|
||||
`diffArrays`/`diffBlocks` keying; isolated changed block → block-aligned hunk →
|
||||
rewritten raw; fences atomic INV-23; insert/delete → exact gap-span hunk;
|
||||
zero-width gap-span anchored INV-41). Split `diffToHunks` into raw
|
||||
`wordEditHunks` + anchoring wrapper (fixes a latent overlap bug under batch apply).
|
||||
- `trackChangesPreview.ts` — document branch uses `diffToBlockHunks`, tags
|
||||
`granularity:"block"`.
|
||||
- `model.ts`/`proposalModel.ts` — additive optional `Proposal.granularity`.
|
||||
- `proposalController.ts` — `acceptBlock`: intra-block word sub-diff
|
||||
(`wordEditHunks`) → one `applyAgentEdit` per changed run, descending offset
|
||||
(INV-40 word-precise attribution; unchanged spans keep prior author).
|
||||
- Tests: `diffToBlockHunks` unit (reconstruction/fence/add-remove); `f12Review`
|
||||
host E2E (M→M, unchanged→none, fence atomic, INV-40, INV-41); updated f11
|
||||
document-path E2E to per-block; `MANUAL-SMOKE-F12.md` §2.
|
||||
|
||||
Verification: `tsc --noEmit` clean; **214 unit** green; **69/5 host E2E** green
|
||||
(main suite 65 → 69 net with the new F12 review tests).
|
||||
|
||||
**Next:** SLICE-3 — #46 (accept-all, INV-42): `acceptAllProposals` (controller,
|
||||
descending-order, orphan-skip + report) + `cowriting.acceptAllProposals` command
|
||||
+ preview toolbar **Accept all** button (`{type:"acceptAll"}` intent, shown ≥2
|
||||
pending).
|
||||
|
||||
## Session arc
|
||||
|
||||
1. **Claim.** Resumed via `/goal next` (after session 0043 shipped #42); claimed
|
||||
session 0044 for #47. Clean `main` baseline.
|
||||
2. **Anchor gate.** #47 (`type/feature`) covered by the graduated combined design
|
||||
→ §4.3 R3 satisfied.
|
||||
3. **Deep code read.** `splitBlocks*`/`diffBlocks`/`diffToHunks`/`EditHunk`,
|
||||
`runEditAndPropose`, `proposalController.accept`/`applyAgentEdit`, and crucially
|
||||
`pendingEdits.matchEvent` (one registration per change event → drove the INV-40
|
||||
sequential-apply decision).
|
||||
4. **TDD `diffToBlockHunks`.** First attempt (coarsen word-hunks) failed a
|
||||
reconstruction case — discovered `diffToHunks` can emit OVERLAPPING hunks
|
||||
(anchorInsertion over-absorbs). Switched to block-key alignment with
|
||||
replacement-from-rewritten-raw → green.
|
||||
5. **Model + wiring + INV-40 accept.** Added `granularity`; document branch →
|
||||
`diffToBlockHunks`; `acceptBlock` intra-block sub-diff. The overlap bug then
|
||||
resurfaced in `acceptBlock` (batch apply) → split `diffToHunks` into raw
|
||||
`wordEditHunks` (disjoint) + anchoring wrapper; `acceptBlock` uses raw → green.
|
||||
6. **Verified + shipped** (214 unit, 69/5 E2E), self-reviewed, PR #50 merged,
|
||||
#47 closed, memory + transcript updated.
|
||||
|
||||
## 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._
|
||||
|
||||
- **INV-40 undo granularity** (driver call). Spec §6.2/§6.7 say accepting a block
|
||||
proposal should be "one undo-grouped WorkspaceEdit." But the F3 seam's
|
||||
`pendingEdits.matchEvent` matches exactly ONE registration per change event, so a
|
||||
single `WorkspaceEdit` with N independent run-replaces can't carry per-run
|
||||
attribution (the multi-hunk event fails to match any single registration → falls
|
||||
through to human). Implemented INV-40 as **sequential per-run `applyAgentEdit`
|
||||
calls, descending offset** — correct word-precise attribution, reuses the seam
|
||||
with zero attribution-core risk, but a block accept is N undo steps rather than
|
||||
one. Single-undo-step would require extending `matchEvent`/`onDidChange` to
|
||||
consume multiple registrations per event (riskier F3 change) — left as a possible
|
||||
follow-up.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Session 0045.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T08-04 (PST)
|
||||
> Type: planning-and-executing
|
||||
> End: 2026-06-13T08-17 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
> Posture: autonomous (yolo)
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
/goal plan-and-execute #46 (SLICE-3 of the document-edit flow — accept-all, INV-42), per specs/coauthoring-document-edit-flow.md
|
||||
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
**SLICE-3 — #46 (accept)** of the document-edit flow
|
||||
(`specs/coauthoring-document-edit-flow.md` §7.2, INV-42). Final slice — completes
|
||||
reach→review→**accept**. Anchor: Feature #46 (`type/feature`); covered by the
|
||||
graduated combined design → §4.3 R3.
|
||||
|
||||
A single **Accept all** gesture applies every pending proposal on the current
|
||||
document through the existing F4 `acceptById` seam (block proposals take the INV-40
|
||||
path automatically), in a re-anchor-safe (descending) order, **skipping** orphans
|
||||
and **reporting** applied-vs-skipped. Batched application of the existing path — no
|
||||
new accept mechanism; the webview posts intent only (INV-35).
|
||||
|
||||
Tasks:
|
||||
1. `proposalController.ts` — `acceptAllProposals(document): Promise<{applied,
|
||||
skipped}>`: snapshot pending, sort descending by resolved anchor start,
|
||||
`acceptById` each **silently** (no per-item orphan warning), tally
|
||||
applied/skipped (orphans counted skipped). Add a `silent` opt to the accept
|
||||
path so the batch suppresses per-proposal warnings.
|
||||
2. `extension.ts` — `cowriting.acceptAllProposals` command (active doc) → reports
|
||||
applied-vs-skipped via a status message.
|
||||
3. `trackChangesPreview.ts` — `ToolbarMsg += {type:"acceptAll"}`;
|
||||
`handleWebviewMessage` routes it → `proposals.acceptAllProposals(document)` +
|
||||
report.
|
||||
4. `media/preview.ts` + `shellHtml` — an **Accept all** toolbar button shown when
|
||||
`summary.proposals >= 2` (and authorable), posting `{type:"acceptAll"}`.
|
||||
5. `package.json` — register the `cowriting.acceptAllProposals` command (palette,
|
||||
markdown-gated).
|
||||
6. Tests: host E2E (N pending → all applied + cleared; orphan skipped + reported;
|
||||
button hidden < 2 pending); `MANUAL-SMOKE-F12.md` §3.
|
||||
|
||||
No new persisted artifact; no deploy pipeline (VS Code extension).
|
||||
|
||||
## Results
|
||||
|
||||
**SLICE-3 (#46, accept) shipped to `main`** — PR
|
||||
[#51](https://git.benstull.org/benstull/vscode-cowriting-plugin/pulls/51)
|
||||
(merged), issue #46 closed. **Completes the document-edit-flow cluster
|
||||
(#42 reach + #47 review + #46 accept).**
|
||||
|
||||
- `proposalController.ts` — `acceptAllProposals(document)` → `{applied, skipped}`
|
||||
(descending order, orphan-skip); `accept`/`acceptById` `silent` opt for the batch.
|
||||
- `trackChangesPreview.ts` — `ToolbarMsg += {type:"acceptAll"}` → public
|
||||
`acceptAll(document)` (batch + report).
|
||||
- `extension.ts`/`package.json` — `cowriting.acceptAllProposals` command
|
||||
(active doc, markdown-gated palette).
|
||||
- `media/preview.ts` + shell — "✓✓ Accept all" toolbar button (intent; shown
|
||||
≥2 pending, authorable, on-state).
|
||||
- `trackChangesModel.ts` — `diffToBlockHunks` fix: emit one block-aligned hunk
|
||||
per changed block **even when adjacent** (changed blocks are 1:1 anchors;
|
||||
gap-spans only cover add/remove runs) — caught by the accept-all E2E (3 adjacent
|
||||
changed blocks were collapsing to 1 proposal).
|
||||
- `f12Accept.test.ts` E2E + `MANUAL-SMOKE-F12.md` §3.
|
||||
|
||||
Verification: `tsc --noEmit` clean; **214 unit** green; **73/5 host E2E** green
|
||||
(main suite 69 → 73 with the new accept-all tests).
|
||||
|
||||
**Next:** the cluster is complete; no in-flight next step. Open backlog includes
|
||||
#48 (pin → fully clean review panel, story P2) and the OQ-2 F11 (#43) spec
|
||||
graduation (hygiene). A natural hand-back point for operator direction.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. **Claim.** Continued the rolling `next` goal after 0044 shipped #47; claimed
|
||||
session 0045 for #46. Clean `main`.
|
||||
2. **Plan + implement.** Controller `acceptAllProposals` (+ `silent` accept opt);
|
||||
`acceptAll` intent route + public method; command + package.json; "Accept all"
|
||||
toolbar button (≥2-pending gating in the sealed webview).
|
||||
3. **E2E caught a real bug.** The accept-all "3 adjacent changed blocks" case
|
||||
returned 1 proposal, not 3 — `diffToBlockHunks` was merging adjacent changed
|
||||
blocks into one gap-span run. Fixed by treating `changed` blocks as 1:1 anchors
|
||||
(each → its own block-aligned hunk; gap-spans only span add/remove runs).
|
||||
4. **Verified + shipped** (214 unit, 73/5 E2E), self-reviewed, PR #51 merged,
|
||||
#46 closed → **cluster complete**; memory + transcript updated.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Empty if none._
|
||||
|
||||
- _No low-confidence calls this session._ (The `diffToBlockHunks` adjacency
|
||||
behavior was a bug caught by the accept-all E2E, not a judgment call.)
|
||||
@@ -0,0 +1,95 @@
|
||||
# Session 0046.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T08-19 (PST)
|
||||
> Type: planning-and-executing
|
||||
> End: 2026-06-13T08-31 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
> Posture: autonomous (yolo)
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
/goal plan-and-execute #48 (pinning the baseline leaves the review panel fully un-annotated — zero-diff → no F3 authorship colors on unchanged blocks; proposals still show)
|
||||
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
**#48 (story, P2)** — pinning the baseline leaves the review panel fully
|
||||
un-annotated. Anchor: leaf `story` → §4.3 R2 (no design gate). Picked
|
||||
autonomously as the next backlog item after the document-edit-flow cluster shipped
|
||||
(0043/0044/0045).
|
||||
|
||||
When `diffBlocks(baseline, current)` yields all-`unchanged` ops (zero diff — the
|
||||
state right after a pin), the F10 on-render still author-colors every block
|
||||
(`colorByAuthor`), so the panel looks painted instead of clean. Fix: in that
|
||||
zero-diff case render clean (no author coloring, no change marks) while keeping
|
||||
`data-src` mapping (INV-36) and still injecting pending proposals (proposals are
|
||||
actions, not annotations). Narrow edge case of INV-33 — with-changes render
|
||||
unchanged; off-state unchanged; broader "authorship never colors unchanged" NOT
|
||||
in scope.
|
||||
|
||||
Tasks:
|
||||
1. `trackChangesModel.ts` `renderReview` — detect `ops.every(unchanged)`; in that
|
||||
case use a plain `render` (skip `colorByAuthor`) for blocks; proposal injection
|
||||
loop unchanged.
|
||||
2. Unit: baseline==current + author spans → no `cw-by-claude`/`cw-by-human` (and
|
||||
no `cw-add`/`cw-del`) on the body; with a pending proposal → the `cw-proposal`
|
||||
block still renders.
|
||||
3. Host E2E: open preview, diverge + author-color, pin → panel clean (no
|
||||
green/blue); edit again → annotations return.
|
||||
4. Content repo: one-line INV-33 clarification (zero-diff → clean on-render) in
|
||||
`specs/coauthoring-interactive-review.md`.
|
||||
|
||||
No new persisted artifact; no deploy pipeline (VS Code extension).
|
||||
|
||||
## Results
|
||||
|
||||
**#48 (story, P2) shipped to `main`** — PR
|
||||
[#52](https://git.benstull.org/benstull/vscode-cowriting-plugin/pulls/52)
|
||||
(merged), issue #48 closed. Picked autonomously after the document-edit-flow
|
||||
cluster (0043/0044/0045).
|
||||
|
||||
- `trackChangesModel.ts` — `renderReview` gains a `pinned` `RenderOption`; when
|
||||
pinned + zero-diff, blocks render plain (skip `colorByAuthor`).
|
||||
- `trackChangesPreview.ts` — pass `{ pinned: baseline?.reason === "pinned" }`
|
||||
from `refresh` + `renderHtmlFor`.
|
||||
- Unit (4 new) + `s48PinClean` host E2E.
|
||||
|
||||
**Scoped to the pin specifically** (not all zero-diff): a baseline advanced by a
|
||||
**machine-landing** (accept) is also zero-diff but keeps its authorship coloring
|
||||
(F10 INV-33) — the F10 authorship E2E caught a pure-zero-diff rule would regress
|
||||
accepted-Claude-text coloring, so the clean render is gated on `reason ===
|
||||
"pinned"`.
|
||||
|
||||
Verification: `tsc --noEmit` clean; **218 unit** green; **74/5 host E2E** green.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. **Pick + claim.** Stop hook required determining/executing the next milestone
|
||||
autonomously (rolling `next`); chose #48 (leaf story, no design gate) and
|
||||
claimed 0046. Clean `main`.
|
||||
2. **Read the issue** (detailed, gave the solution shape) + `renderReview`.
|
||||
3. **TDD.** Implemented zero-diff-clean; full suite caught the F10 authorship
|
||||
regression (accept advances baseline → zero-diff → coloring was being cleared);
|
||||
re-scoped to `reason === "pinned"` via a `pinned` RenderOption → all green.
|
||||
4. **Shipped** (PR #52), closed #48; spec clarification written but left in the
|
||||
content-repo working tree (see Deferred decisions).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Empty if none._
|
||||
|
||||
- **INV-33 clarification not pushed to the content repo** (loose end, not a
|
||||
judgment call). Wrote the zero-diff-after-pin clarification into
|
||||
`specs/coauthoring-interactive-review.md`, but the **content repo has
|
||||
pre-existing uncommitted state that isn't this session's**: a modified
|
||||
`coauthoring-diff-view.md` and ~18 untracked capture-session draft files
|
||||
(`issues/*.md`, `specs/coauthoring-document-edit-flow.md`), and local `main` is
|
||||
**6 behind origin**. A clean rebase would require deleting the operator's
|
||||
untracked drafts (irreversible — a STOP gate), so I **soft-reset** my spec
|
||||
commit into the working tree rather than force it. The clarification sits as an
|
||||
uncommitted modification alongside the operator's other content-repo drafts,
|
||||
for the operator to reconcile/push. The #48 **code** shipped normally.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Session 0047.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T08-34 (PST)
|
||||
> Type: planning-and-executing
|
||||
> End: 2026-06-13T08-44 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
> Posture: autonomous (yolo)
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
/goal plan-and-execute #33 (intra-emphasis sentinel hardening — token-aware fix for the 2 characterized failure modes)
|
||||
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
**#33 (task, P3)** — harden the F9/F10 author-coloring PUA sentinels against
|
||||
intra-emphasis markdown. Anchor: leaf `task` → §4.3 R2 (no design gate). Picked
|
||||
autonomously (next clean plan-and-executable backlog item; doesn't touch the
|
||||
content repo). Approach = the token-aware fix recommended in the issue #33 comment.
|
||||
|
||||
Two characterized failure modes (issue #33 comment, session 0032):
|
||||
- **CASE1** — a span boundary lands strictly inside a delimiter run (`a**b**c`,
|
||||
boundary between the two `*`): the injected sentinel splits `**` → markdown parse
|
||||
breaks (stray `<em></em>`, raw `**` left).
|
||||
- **CASE3** — a span boundary inside an emphasis run (`**bold**`, span covers
|
||||
`**bo`): emphasis renders but the author `<span>` and `<strong>` **misnest**
|
||||
(`<strong>bo</span>ld</strong>`).
|
||||
|
||||
Fix (both needed):
|
||||
1. `injectSentinels` — **clamp** any sentinel offset that lands strictly inside a
|
||||
markdown delimiter run (`* _ ~ \``) to the run's start, so a sentinel never
|
||||
splits a delimiter (fixes CASE1). Skip now-zero-width spans.
|
||||
2. `sentinelsToSpans` — replace the naive split/join with a **token-aware walker**
|
||||
over the rendered HTML: emit `cw-by-*` spans only around TEXT runs, closing the
|
||||
span before any `<tag>` and reopening after, so a span never crosses an element
|
||||
boundary (fixes CASE3 — one span segment per text run). Strip any stray sentinel
|
||||
left inside a tag (no PUA leakage).
|
||||
|
||||
Pure/vscode-free/deterministic (INV-33). Non-goals: link/attribute sentinel cases,
|
||||
visual language, attribution model.
|
||||
|
||||
Tasks: unit tests reproducing CASE1 + CASE3 (+ regression on existing
|
||||
colorByAuthor/renderReview cases) → implement clamp + walker → green; spec already
|
||||
notes the hardening (§1.7/§9). No deploy pipeline (extension).
|
||||
|
||||
## Results
|
||||
|
||||
**#33 (task, P3) shipped to `main`** — PR
|
||||
[#53](https://git.benstull.org/benstull/vscode-cowriting-plugin/pulls/53)
|
||||
(merged), issue #33 closed. Picked autonomously (next clean plan-and-executable
|
||||
leaf; doesn't touch the content repo).
|
||||
|
||||
- `trackChangesModel.ts` `injectSentinels` — clamp any sentinel offset landing
|
||||
strictly inside a delimiter run (`* _ ~ \``) to the run's start (fixes CASE1
|
||||
parse-break); drop spans that clamp to empty.
|
||||
- `trackChangesModel.ts` `sentinelsToSpans` — token-aware walker emitting the
|
||||
`cw-by-*` span only around text runs, split at every `<tag>` boundary (fixes
|
||||
CASE3 misnest); strays stripped (no PUA leak).
|
||||
- 4 new unit tests (CASE1/CASE3/CASE2-regression/no-leak), real markdown-it via
|
||||
`renderReview`.
|
||||
|
||||
Verification: `tsc --noEmit` clean; **222 unit** green; **74/5 host E2E** green;
|
||||
the F10/authorship E2E (`cw-by-claude`/`cw-by-human`) still pass (common case
|
||||
byte-identical).
|
||||
|
||||
## Session arc
|
||||
|
||||
1. Stop hook required autonomously determining/executing the next milestone;
|
||||
chose #33 (leaf task, no content-repo dependency). Claimed 0047, clean `main`.
|
||||
2. Read issue #33 + its investigation comment (2 failure modes, recommended
|
||||
token-aware approach) + session 0032 characterization + the sentinel code.
|
||||
3. TDD: CASE1/CASE3 reproduced red → implemented clamp + walker → green; full
|
||||
suite + E2E green; self-reviewed (one pure module).
|
||||
4. Shipped (PR #53), closed #33.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Empty if none._
|
||||
|
||||
- _No low-confidence calls this session._ (Approach was the one recommended in the
|
||||
issue #33 comment; the fix is a pure-module robustness change with full test
|
||||
coverage and no regression.)
|
||||
@@ -0,0 +1,90 @@
|
||||
# Session 0048.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T08-45 (PST)
|
||||
> Type: planning-and-executing
|
||||
> End: 2026-06-13T08-58 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
> Posture: autonomous (yolo)
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
/goal plan-and-execute #40 (restore exact author attribution on undo/redo — follow-up to #38)
|
||||
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
**#40 (task, P3)** — restore exact author attribution on undo/redo (follow-up to
|
||||
#38). Anchor: leaf `task` → §4.3 R2 (no design gate). Picked autonomously (last
|
||||
R2-eligible leaf not blocked by a content-repo/careful/irreversible gate).
|
||||
|
||||
#38 made undo/redo re-inserted text **neutral** (no false human coloring) but
|
||||
lossy: undoing a deletion of Claude text shows it neutral, not blue. #40 restores
|
||||
the **exact prior** attribution.
|
||||
|
||||
**Mechanism (engineering choice): text-keyed attribution snapshots.** Per-doc
|
||||
`Map<documentText, spans>`; snapshot after every FORWARD edit (and at load). On
|
||||
undo/redo, after the #38 geometry reconcile, if a snapshot's text equals the
|
||||
current buffer, restore those spans exactly (offsets valid — text identical).
|
||||
Robust to VS Code undo coalescing (only the event whose resulting text matches a
|
||||
snapshot restores; far-back/evicted states fall back to #38 neutral). Bounded
|
||||
history.
|
||||
|
||||
Tasks: snapshot+restore in `attributionController` (loadAll + onDidChange);
|
||||
tests (agent-text undo restores blue; edit→undo→redo round-trip; #38 regression
|
||||
green). No deploy pipeline (extension).
|
||||
|
||||
## Results
|
||||
|
||||
**#40 implemented but NOT shipped — verification-blocked.** On branch
|
||||
`s40-undo-provenance` (pushed, unmerged); issue #40 **kept open**.
|
||||
|
||||
- `attributionController.ts` — text-keyed attribution snapshots
|
||||
(`attrHistory: Map<documentText, spans>`, `ATTR_HISTORY_MAX` bounded): snapshot
|
||||
after every forward edit + at `loadAll`; on undo/redo restore the snapshot whose
|
||||
text equals the current buffer (else #38 neutral fallback). Robust to undo
|
||||
coalescing.
|
||||
- `test/e2e/suite/s40Provenance.test.ts` — agent-text-undo-restores-blue +
|
||||
edit→undo→redo round-trip.
|
||||
- **222 unit + typecheck green.**
|
||||
|
||||
**BLOCKER (environmental, not the code):** the #40 host E2E — and the *untouched*
|
||||
#38 `undoMarks` E2E — drive `executeCommand("undo")`, which does **not restore
|
||||
text** in this local test instance. Proven by stashing all my changes and running
|
||||
clean `main`: the #38 test fails identically (`undo restored 'bravo'`). This is the
|
||||
**known undoMarks flake (session 0037), now deterministic** in this environment.
|
||||
The usual remedy — clearing `.vscode-test/user-data` — is **permission-blocked**
|
||||
this session (`rm -rf` denied). Focusing the doc before `undo` (`showTextDocument`)
|
||||
did not help. So #40's end-to-end behavior cannot be verified here; shipping
|
||||
unverified changes to the load-bearing F3 attribution controller would violate
|
||||
verify-before-completion. Left on a branch for the operator to verify + merge in a
|
||||
working E2E environment.
|
||||
|
||||
**Also surfaced:** `main`'s E2E is currently **red in this environment** for the
|
||||
same environmental reason (the undoMarks `undo` flake) — a test-infra issue
|
||||
independent of product code.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. Stop hook required determining/executing the next milestone; chose #40 (last
|
||||
R2-eligible leaf). Claimed 0048, clean `main`.
|
||||
2. Read #40 + #38 + the attribution change handler; chose text-keyed snapshots.
|
||||
3. TDD: wrote #40 E2E + implemented snapshot/restore; 222 unit green.
|
||||
4. E2E: 3 undo-driven tests failed at the `undo restored X` step. Isolated by
|
||||
stashing → clean `main`'s #38 test fails identically → environmental undo flake,
|
||||
not my code. Could not clear `.vscode-test` (permission-blocked).
|
||||
5. Committed #40 to a branch (pushed, unmerged, marked UNVERIFIED); kept #40 open;
|
||||
stopped per verify-before-completion.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Empty if none._
|
||||
|
||||
- **Did not merge #40** (driver call). Alternative: merge on unit-green + reasoning
|
||||
alone. Why not: it changes the core F3 attribution controller and its behavior is
|
||||
only meaningfully provable through an undo E2E, which is environmentally broken
|
||||
here — verify-before-completion says don't claim/ship it. Preserved on a branch
|
||||
for operator verification instead. (Reversible: just merge once verified.)
|
||||
@@ -0,0 +1,61 @@
|
||||
# Session 0049.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T09-19 (PST)
|
||||
> Type: planning-and-executing
|
||||
> End: 2026-06-13T09-24 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
> Posture: autonomous (yolo)
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
/goal plan-and-execute: pretest:e2e cleans stale compiled tests in out/test (stale *.test.js from other branches were running via the glob) — build hygiene follow-up to #54
|
||||
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
Build-hygiene follow-up to #54 (noted on that issue). `tsc -p tsconfig.e2e.json`
|
||||
emits to `out/` but never removes outputs for test sources absent on the current
|
||||
branch, so stale compiled `out/test/e2e/suite/*.test.js` from other branches get
|
||||
run by the suite glob (`**/*.test.js`) — this caused real cross-branch test
|
||||
confusion in session 0048 (a removed probe + the s40 branch's tests ran on an
|
||||
unrelated branch). Fix: `pretest:e2e` cleans `out/test` before recompiling.
|
||||
|
||||
- `package.json` — add `clean:e2e` (node `fs.rmSync('out/test', {recursive,force})`
|
||||
— avoids shell `rm` issues) and run it between `build` and `tsc` in `pretest:e2e`.
|
||||
Clean ONLY `out/test` (NOT `out/`, which holds the just-built esbuild bundle).
|
||||
- Verify: introduce a stale `out/test/.../zz.test.js`, run `pretest:e2e`, confirm
|
||||
it's gone + the E2E suite is green.
|
||||
|
||||
Trivial, ungated, verifiable; test-infra only. No deploy pipeline (extension).
|
||||
|
||||
## Results
|
||||
|
||||
**Shipped to `main`** — PR #56 (merged). `package.json`: new `clean:e2e`
|
||||
(`fs.rmSync('out/test',{recursive,force})`) run between `build` and `tsc` in
|
||||
`pretest:e2e`. Cleans only `out/test` (never the esbuild bundle in `out/`).
|
||||
Verified: a planted stale `out/test/.../zz.test.js` is removed by `pretest:e2e`;
|
||||
E2E green (73 passing + 1 pending [#38 undo-skip], both passes exit 0). Test-infra
|
||||
only; no product code.
|
||||
|
||||
Properly tracked under this session (0049) — closing the protocol gap where
|
||||
#54/#55 merged after 0048 had finalized.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. Stop hook pushed me to execute the one remaining ungated/verifiable item (the
|
||||
`out/`-clean follow-up I'd noted on #54). Claimed 0049 to track it cleanly.
|
||||
2. Confirmed `tsc` outDir → `out/test`; esbuild bundle → `out/extension.cjs`
|
||||
(separate), so cleaning `out/test` is safe.
|
||||
3. Implemented `clean:e2e`; verified by planting a stale compiled test (removed)
|
||||
+ full E2E green. Shipped PR #56.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Empty if none._
|
||||
|
||||
- _No low-confidence calls this session._ (Cleaning stale build output is
|
||||
unambiguously correct; scoped to `out/test` to protect the esbuild bundle.)
|
||||
@@ -0,0 +1,76 @@
|
||||
# Session 0050.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T17-43 (PST)
|
||||
> End: 2026-06-13T17-52 (PST)
|
||||
> Type: capture
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture` — "When changing documents, we should decide what happens to the
|
||||
review pane of the current document, and if the document switched to should get a
|
||||
review experience or not"
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `main`, clean, pushed (origin/main). No in-flight sessions for the app.
|
||||
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`); content
|
||||
repo `vscode-cowriting-plugin-content` cloned, with ~18 prior uncommitted
|
||||
capture drafts in `issues/`.
|
||||
- Prior frontier exhausted (session 0047 note): remaining backlog items gated;
|
||||
operator directing. This is a capture session, not a build.
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Claimed** tracked-lite session ID **0050** (`claim-session-id.sh --type
|
||||
capture`). Resolved app + content repo (`resolve-app.py`).
|
||||
2. **Grounded** the ask by reading the code (Explore subagent over
|
||||
`src/trackChangesPreview.ts`, `diffViewController.ts`, `proposalController.ts`):
|
||||
confirmed the review pane is one-panel-per-doc keyed by URI
|
||||
(`trackChangesPreview.ts:45`) with **no** `onDidChangeActiveTextEditor`
|
||||
listener — so on document switch the pane **stays pinned** to its original doc
|
||||
(incidental, not designed) and the switched-to doc gets no review until
|
||||
`showTrackChangesPreview` is re-invoked. Per-doc review state (F6 baseline / F4
|
||||
proposals / F3 attribution) auto-creates on demand.
|
||||
3. **Sized** the single ask: genuine forks (follow / pin / close; auto-review /
|
||||
on-demand) + needs design before build → `type/feature` (R3), `priority/P2`.
|
||||
Captures the decision-to-be-made; option space goes in Solution notes,
|
||||
non-binding.
|
||||
4. **Drafted** `issues/review-pane-on-document-switch.md` in the content repo
|
||||
(working tree only, INV-8), full §5 template. Scanned clean for secrets
|
||||
(INV-3).
|
||||
5. **Operator approved** filing as-is (feature, P2) via AskUserQuestion.
|
||||
6. **Filed** → ensured labels (all pre-existing), then `capture-issues.sh` →
|
||||
**#57** (https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/57).
|
||||
|
||||
## Cut state
|
||||
|
||||
- Session repo `vscode-cowriting-plugin`: clean `main`, nothing committed this
|
||||
session (capture touches only the content repo working tree). No PRs.
|
||||
- Content repo: new draft `issues/review-pane-on-document-switch.md` left
|
||||
uncommitted for the Author to publish/discard (INV-8), alongside the prior
|
||||
drafts already there. Not pushed by this skill.
|
||||
- Issue **#57** filed and open on the tracker.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — single clear ask, operator approved the framing and type before filing._
|
||||
|
||||
## Operator plate
|
||||
|
||||
- One feature filed: **#57** (review-pane behavior on document switch, P2).
|
||||
- ~19 capture drafts now sit uncommitted in `vscode-cowriting-plugin-content` —
|
||||
yours to commit/publish (or discard) when convenient.
|
||||
- #57 needs a design before build (R3). No active plan frontier; operator to
|
||||
direct next.
|
||||
|
||||
## Next session prompt
|
||||
|
||||
`/goal` — operator to direct. Candidate: brainstorm a Solution Design for **#57**
|
||||
(review-pane behavior on document switch) — settle the two coupled rules (outgoing
|
||||
pane fate; whether the switched-to doc auto-gets a review experience) before
|
||||
building. Other open: OQ-2 (F11 graduation), #32 (scroll-sync, needs design),
|
||||
#35 (repo rename), #40 (undo provenance, P3, branch `s40-undo-provenance`
|
||||
unmerged), #54 (undo still broken in sandbox).
|
||||
@@ -0,0 +1,71 @@
|
||||
# Session 0051.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T17-49 (PST)
|
||||
> End: 2026-06-13T17-58 (PST)
|
||||
> Type: capture
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture` — two asks:
|
||||
1. "Undo doesn't undo Claude's edits"
|
||||
2. "When I make a change within a block that Claude has changed that I haven't
|
||||
approved, it doesn't show my changes"
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `main`, clean, pushed. Immediately followed capture session 0050 (#57).
|
||||
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`); content
|
||||
repo cloned.
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Claimed** tracked-lite session ID **0051**.
|
||||
2. **Grounded** both asks in code (Explore subagent):
|
||||
- Undo: Claude's edits are pending proposals; `proposalController.ts:303-310`
|
||||
shifts anchors on edit but ignores `e.reason === Undo` — proposal not
|
||||
reconciled on undo (accepted edits hit the doc and are undoable, but the
|
||||
pending proposal lingers).
|
||||
- Pending-block edits: a proposal freezes its "before" (`replaced: fp.text`,
|
||||
`proposalController.ts:81-95`); the block renders from current text but the
|
||||
proposal's frozen view (`trackChangesModel.ts:706-722`/`:769-805`) obscures
|
||||
live human edits in that block.
|
||||
3. **Checked duplication** against the undo cluster — #38 (wrong marks, closed),
|
||||
#40 (attribution color, open), #54 (undo E2E infra, open). Both new asks are
|
||||
distinct (undo failing to *reverse* the edit; live edits obscured by a pending
|
||||
proposal). Verified `type/bug` exists on the tracker.
|
||||
4. **Drafted** both as `type/bug` (P1) in the content repo working tree
|
||||
(`issues/undo-does-not-undo-claude-edits.md`,
|
||||
`issues/edits-in-pending-block-not-shown.md`), each framed as user-facing pain
|
||||
with repro steps + code diagnosis demoted to parenthetical. Scanned clean for
|
||||
secrets (INV-3).
|
||||
5. **Operator triage:** before filing, operator said "ignore both of these
|
||||
issues — you can delete the drafts." Both drafts **deleted**; nothing filed.
|
||||
|
||||
## Cut state
|
||||
|
||||
- **Nothing filed** (INV-4 — no-actionable outcome after operator triage).
|
||||
- Both draft files removed from the content repo working tree.
|
||||
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — operator explicitly directed dropping both asks._
|
||||
|
||||
## Operator plate
|
||||
|
||||
- No issues filed this session. The two undo/pending-block asks were
|
||||
considered and intentionally dropped (recorded in memory
|
||||
`session-0051-capture-no-actionable.md` so they aren't re-raised as new).
|
||||
- Note: these remain *real* observed behaviors per the code reading — dropped on
|
||||
operator triage, not because they were invalid. If they resurface, the prior
|
||||
grounding is in the memory note.
|
||||
|
||||
## Next session prompt
|
||||
|
||||
`/goal` — operator to direct. From session 0050: candidate is brainstorming a
|
||||
Solution Design for **#57** (review-pane behavior on document switch). Other
|
||||
open: OQ-2 (F11 graduation), #32 (scroll-sync), #35 (repo rename), #40 (undo
|
||||
provenance, P3, branch `s40-undo-provenance` unmerged), #54 (undo E2E infra).
|
||||
@@ -0,0 +1,73 @@
|
||||
# Session 0052.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-15T08-33 (PST)
|
||||
> End: 2026-06-15T08-41 (PST)
|
||||
> Type: capture
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture` — two asks (second arrived mid-session):
|
||||
1. "the review window and markdown source file should have scroll location sync'd"
|
||||
2. "the claude change recommendation block (which user will accept or decline)
|
||||
should show the diff with the original"
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `main`, clean, pushed. Follows capture sessions 0050 (#57) and 0051
|
||||
(no-actionable).
|
||||
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`).
|
||||
|
||||
## Arc
|
||||
|
||||
**Ask 1 — scroll-sync (duplicate, nothing filed):**
|
||||
1. Claimed tracked-lite session ID **0052**.
|
||||
2. Recognized scroll-sync as a known F10 follow-up; fetched **#32** — exact match
|
||||
(*"Scroll-sync the review preview with the source editor"*, open,
|
||||
`type/feature`, `priority/P3`, filed session 0031).
|
||||
3. Surfaced to operator; operator chose to **leave #32 at P3**. No new issue
|
||||
(INV-4 duplicate).
|
||||
|
||||
**Ask 2 — proposal block diff vs original (filed #58):**
|
||||
4. Grounded in code (Explore): the pending proposal block renders original +
|
||||
proposed as **two separate full blocks** (`<del>` whole-before + `<ins>`
|
||||
whole-after, `trackChangesModel.ts:715-716`) with **no word-level diff** — the
|
||||
changed-block rendering already uses `wordMergedMarkdown`/`diffWords`
|
||||
(`trackChangesModel.ts:431-438`) but `proposalBlockHtml()` doesn't.
|
||||
`ProposalView` already carries `replaced`+`replacement`
|
||||
(`proposalController.ts:81-95`).
|
||||
5. Sized `type/story`, P2 (reuses existing word-diff helper; distinct from #31
|
||||
placement / #47 granularity). Drafted
|
||||
`issues/proposal-block-shows-diff-with-original.md` (working tree only, secrets
|
||||
scanned).
|
||||
6. Operator approved as-is → ensured labels → filed **#58**
|
||||
(https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/58).
|
||||
|
||||
## Cut state
|
||||
|
||||
- **#58** filed (story, P2). Scroll-sync left as existing **#32** (P3) — nothing
|
||||
new filed for it.
|
||||
- Draft `issues/proposal-block-shows-diff-with-original.md` left uncommitted in
|
||||
the content repo for the Author to publish (INV-8).
|
||||
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — operator directly resolved the duplicate and approved the #58 framing._
|
||||
|
||||
## Operator plate
|
||||
|
||||
- One issue filed: **#58** (proposal block word-diff, P2).
|
||||
- Scroll-sync remains **#32** (P3, open, unchanged).
|
||||
- One uncommitted capture draft added to the content repo (joins the prior
|
||||
drafts) — yours to publish or discard.
|
||||
|
||||
## Next session prompt
|
||||
|
||||
`/goal` — operator to direct. Standing candidate (from 0050): brainstorm a
|
||||
Solution Design for **#57** (review-pane behavior on document switch). Open work:
|
||||
#58 (proposal word-diff, P2, story), #32 (scroll-sync, P3), OQ-2 (F11
|
||||
graduation), #35 (repo rename), #40 (undo provenance, P3, branch
|
||||
`s40-undo-provenance` unmerged), #54 (undo E2E infra).
|
||||
@@ -0,0 +1,66 @@
|
||||
# Session 0053.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-15T10-23 (PST)
|
||||
> End: 2026-06-15T10-29 (PST)
|
||||
> Type: capture
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture` — "The plugin is asking for access to other applications but works
|
||||
fine when I decline. It just shouldn't"
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `main`, clean, pushed. Follows capture sessions 0050 (#57), 0051
|
||||
(no-actionable), 0052 (#58 + scroll-sync dup).
|
||||
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`).
|
||||
|
||||
## Arc
|
||||
|
||||
1. Claimed tracked-lite session ID **0053**.
|
||||
2. **Investigated** the macOS Automation/Apple-Events trigger (Explore): the
|
||||
extension's own source has **no** AppleScript/`osascript`/automation calls; the
|
||||
probable origin is **`@cline/sdk`/`@cline/core`** during agent activation
|
||||
(likely local Claude Code hub-discovery), which falls back gracefully when
|
||||
denied — matching "works fine when declined." Framed as a lead, not a settled
|
||||
root cause.
|
||||
3. **Sized** `type/bug` (declared defect: unwanted OS permission request).
|
||||
Drafted `issues/spurious-automation-permission-prompt.md` (working tree only,
|
||||
secrets scanned) with repro steps + expected/actual + the upstream caveat.
|
||||
4. **Operator set priority P1** (trust/launch-optics over the harmless functional
|
||||
impact); updated the WSJF line accordingly.
|
||||
5. **Filed #59.** capture-issues.sh `--type` rejects `bug` (known #124 taxonomy
|
||||
gap), so filed with `--type task` then swapped the label to `type/bug` via the
|
||||
Gitea API (deleted type/task, added type/bug) — verified `['priority/P1',
|
||||
'type/bug']` (INV-2 holds).
|
||||
→ https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/59
|
||||
|
||||
## Cut state
|
||||
|
||||
- **#59** filed (bug, P1), label corrected to exactly `type/bug`.
|
||||
- Draft `issues/spurious-automation-permission-prompt.md` left uncommitted in the
|
||||
content repo for the Author to publish (INV-8).
|
||||
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — operator set the priority directly and approved the framing._
|
||||
|
||||
## Operator plate
|
||||
|
||||
- One issue filed: **#59** (spurious macOS automation prompt, bug, **P1**).
|
||||
- The cause is likely upstream in `@cline/sdk` — the fix may be a config/flag or
|
||||
dependency update, not necessarily this repo's code.
|
||||
- Reminder: bug capture needs the file-as-task-then-relabel workaround until
|
||||
plugin #124 (capture can't type bugs) is fixed.
|
||||
|
||||
## Next session prompt
|
||||
|
||||
`/goal` — operator to direct. Standing candidate (from 0050): brainstorm a
|
||||
Solution Design for **#57** (review-pane behavior on document switch). Open work:
|
||||
**#59** (automation prompt, bug P1 — may be upstream), #58 (proposal word-diff,
|
||||
story P2), #32 (scroll-sync, P3), OQ-2 (F11 graduation), #35 (repo rename), #40
|
||||
(undo provenance, P3), #54 (undo E2E infra).
|
||||
@@ -0,0 +1,66 @@
|
||||
# Session 0054.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-15T10-42 (PST)
|
||||
> End: 2026-06-15T10-48 (PST)
|
||||
> Type: capture
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture` — "See the Claude output/progress in the 'asking Claude…' status"
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `main`, clean, pushed. Follows capture sessions 0050 (#57), 0051
|
||||
(no-actionable), 0052 (#58 + scroll-sync dup), 0053 (#59).
|
||||
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`).
|
||||
|
||||
## Arc
|
||||
|
||||
1. Claimed tracked-lite session ID **0054**.
|
||||
2. **Grounded** (Explore): the "Cowriting: asking Claude…" status is an opaque
|
||||
`withProgress` notification (`extension.ts:232-233`,
|
||||
`trackChangesPreview.ts:232-233`) awaiting `agent.run()` as one black-box
|
||||
promise (`liveTurn.ts:52`). Key enabler: `@cline/sdk` already emits streaming
|
||||
events (`assistant-text-delta`, `tool-started/updated/finished`,
|
||||
`usage-updated`) via `agent.subscribe()` / an `onEvent` hook, but the extension
|
||||
constructs the Agent with **no hooks** (`liveTurn.ts:47-51`) and discards it.
|
||||
3. **Sized** `type/feature` — implementable by subscribing to existing events, but
|
||||
a real design fork on the surface (notification text vs. OutputChannel vs.
|
||||
status bar vs. webview relay) and content (text/tool/usage/reasoning) → design
|
||||
first (R3). Drafted `issues/show-live-claude-progress.md` (working tree only,
|
||||
secrets scanned).
|
||||
4. **Operator set priority** — chose "adjust priority" → **P1** (opaque wait hurts
|
||||
every turn). Updated WSJF line.
|
||||
5. **Filed #60** (`type/feature`, P1).
|
||||
→ https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/60
|
||||
|
||||
## Cut state
|
||||
|
||||
- **#60** filed (feature, P1).
|
||||
- Draft `issues/show-live-claude-progress.md` left uncommitted in the content repo
|
||||
for the Author to publish (INV-8).
|
||||
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — operator set the priority directly and approved the framing._
|
||||
|
||||
## Operator plate
|
||||
|
||||
- One issue filed: **#60** (live Claude progress, feature, **P1**).
|
||||
- Implementation is largely "subscribe to SDK events already available + pick the
|
||||
progress surface" — needs a small design first (R3).
|
||||
- One uncommitted capture draft added to the content repo — yours to publish or
|
||||
discard.
|
||||
|
||||
## Next session prompt
|
||||
|
||||
`/goal` — operator to direct. Capture run 0050–0054 filed #57, #58, #59, #60
|
||||
(scroll-sync = existing #32). Two P1s now open (**#59** automation prompt,
|
||||
**#60** live progress) plus standing #57 (review-pane on doc switch, design
|
||||
candidate). Other open: #58 (proposal word-diff, P2), #32 (scroll-sync P3), OQ-2
|
||||
(F11 graduation), #35 (repo rename), #40 (undo provenance P3), #54 (undo E2E
|
||||
infra).
|
||||
@@ -0,0 +1,105 @@
|
||||
# Session 0055.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-22T23-19 (PST)
|
||||
> End: 2026-06-26T04-23 (PST)
|
||||
> Type: brainstorming
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-brainstorming highest priority ticket`
|
||||
|
||||
## Plan
|
||||
|
||||
Brainstorming session: explore the highest-priority open ticket and write its
|
||||
Solution-Design spec. Highest-priority that fits a brainstorming session (spec
|
||||
output; a feature needs a design per §4.3 R3) was **#60 — Show Claude's live
|
||||
output/progress during the "asking Claude…" status** (P1 feature). #59 (P1 bug)
|
||||
is coding-session work, not brainstorming.
|
||||
|
||||
Output: `specs/coauthoring-live-progress.md` (Solution Design), submitted graduated
|
||||
at finalize to the content repo's `specs/` collection.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Clean, pushed `main` (`24e329e`). No in-flight sessions (next ID 0055).
|
||||
- Two P1 tickets tied for highest priority: **#59** (P1 bug — spurious macOS
|
||||
"control other applications" prompt) and **#60** (P1 feature — live turn
|
||||
progress). Brainstorming produces a spec; a feature needs a design (§4.3 R3),
|
||||
so #60 was the fit. #59 (bug) → a coding session.
|
||||
- Highest INV in use across the content-repo specs: **INV-42**.
|
||||
|
||||
## Session arc (turn-by-turn)
|
||||
|
||||
1. **Gate + classify.** Launch prompt classified as **brainstorming**; ran
|
||||
`wgl-brainstorming`.
|
||||
2. **Claim (peek → gate → claim).** `claim-session-id.sh --dry-run` → no in-flight
|
||||
sessions, next ID **0055**. Claimed `--type brainstorming` (placeholder at
|
||||
`16cca30`). Verified clean pushed `main` baseline.
|
||||
3. **Orient.** Surveyed the open tracker. Picked **#60** (P1 feature, design
|
||||
needed) over #59 (P1 bug, coding-session work). Read #60 in full.
|
||||
4. **Ground in code (read-only).** Read `liveTurn.ts` (`runEditTurn` →
|
||||
`agent.run()` as one opaque promise; module is deliberately vscode-free), both
|
||||
call sites (`extension.ts:232` editSelection, `trackChangesPreview.ts:232`
|
||||
preview `askClaude` — both wrap the turn in `withProgress("asking Claude…")`),
|
||||
the injectable `editTurn` seam (trackChangesPreview.ts:56, for host-E2E
|
||||
stubbing), and the `@cline/sdk` event API: `@cline/agents` `Agent` exposes
|
||||
`subscribe(listener) → unsubscribe`, `run()`, `abort()`; `AgentRuntimeEvent`
|
||||
(in `@cline/shared/dist/agent.d.ts`) carries `assistant-text-delta`
|
||||
(+`accumulatedText`), `tool-started|updated|finished`, `usage-updated`,
|
||||
lifecycle events. The enabler the issue cites is real.
|
||||
5. **Brainstorm forks (superpowers:brainstorming).** Skipped the visual companion
|
||||
(choices are conceptual/native-UI, not visual mockups). Presented three forks
|
||||
via AskUserQuestion; operator picked all three recommendations:
|
||||
- **Surface** → notification activity-line + token count **and** a shared
|
||||
`"Cowriting"` OutputChannel streaming full assistant text. (Rejected: webview
|
||||
relay can't cover the editSelection path; status-bar-only; OutputChannel-
|
||||
primary.)
|
||||
- **Content** → activity + token count (reasoning text not surfaced).
|
||||
- **Cancellation** → `cancellable` notification → `agent.abort()` →
|
||||
"cancelled", proposes nothing (reflect + cancel button).
|
||||
6. **Design presented + approved.** Three-unit architecture (pure
|
||||
`turnProgress.ts` reducer → `runEditTurn` extended with `onProgress` +
|
||||
`AbortSignal` → both call sites relay via a shared `liveProgressUi`); layering
|
||||
rule keeps `liveTurn.ts`/`turnProgress.ts` vscode-free; INV-43..47. Two
|
||||
sub-decisions (OutputChannel auto-reveal gated by a new setting; append-not-
|
||||
clear) confirmed. Operator: "design looks right."
|
||||
7. **Spec written + self-review.** `specs/coauthoring-live-progress.md` authored
|
||||
in house Solution-Design format. Self-review: no placeholders, INV-43..47 (no
|
||||
collision with ≤42; INV-8/21/39 are correct citations), internally consistent,
|
||||
single-feature scope.
|
||||
8. **Operator: "Implement it."** Per the pipeline, that's the brainstorm→coding
|
||||
handoff (one SPEC = one plan = one execution, §4.3). Finalized this
|
||||
brainstorming session (below); a fresh `wgl-planning-and-executing` session
|
||||
plans + builds #60.
|
||||
|
||||
## Cut state (at finalize)
|
||||
|
||||
- **Spec graduated.** `submit-spec.sh --status graduated` →
|
||||
`vscode-cowriting-plugin-content` `specs/coauthoring-live-progress.md` at
|
||||
`3bd1ae5`.
|
||||
- **Code repo:** clean `main`; no code changes this session (brainstorming).
|
||||
A local stray `specs/coauthoring-live-progress.md` remains untracked in the code
|
||||
repo (an `rm` cleanup was permission-denied; harmless — canonical copy is in the
|
||||
content repo).
|
||||
- **Memory:** added `session-0055-live-progress-spec-graduated.md` + index line.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/wgl-planning-and-executing implement #60 (live turn progress) from coauthoring-live-progress.md
|
||||
```
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **OutputChannel auto-reveal** (fires every Claude turn): decided to auto-`show(true)`
|
||||
(preserveFocus) on the *first* text delta, gated by a new setting
|
||||
`cowriting.liveProgress.revealOutput` (default `true`). Operator confirmed the
|
||||
design including this.
|
||||
- **OutputChannel history:** decided to *append* per-turn (with a header) rather
|
||||
than clear, so it doubles as a debug log. Operator confirmed.
|
||||
- **Stray local spec file:** `rm -rf specs/` was permission-denied; left the
|
||||
untracked local copy in the code repo (cosmetic; content repo holds the
|
||||
canonical copy).
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
# Session 0032.0 — Transcript
|
||||
# Session 0056.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-12T01-20 (PST)
|
||||
> Start: 2026-06-26T04-24 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0032 for vscode-cowriting-plugin. The driver replaces this
|
||||
> This file reserves session ID 0056 for vscode-cowriting-plugin. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0032.0-TRANSCRIPT-2026-06-12T01-20--<end>.md form at session end.
|
||||
> SESSION-0056.0-TRANSCRIPT-2026-06-26T04-24--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
@@ -94,5 +94,77 @@
|
||||
},
|
||||
"0032": {
|
||||
"title": ""
|
||||
},
|
||||
"0033": {
|
||||
"title": ""
|
||||
},
|
||||
"0034": {
|
||||
"title": ""
|
||||
},
|
||||
"0035": {
|
||||
"title": ""
|
||||
},
|
||||
"0036": {
|
||||
"title": ""
|
||||
},
|
||||
"0037": {
|
||||
"title": ""
|
||||
},
|
||||
"0038": {
|
||||
"title": ""
|
||||
},
|
||||
"0039": {
|
||||
"title": ""
|
||||
},
|
||||
"0040": {
|
||||
"title": ""
|
||||
},
|
||||
"0041": {
|
||||
"title": ""
|
||||
},
|
||||
"0042": {
|
||||
"title": ""
|
||||
},
|
||||
"0043": {
|
||||
"title": ""
|
||||
},
|
||||
"0044": {
|
||||
"title": ""
|
||||
},
|
||||
"0045": {
|
||||
"title": ""
|
||||
},
|
||||
"0046": {
|
||||
"title": ""
|
||||
},
|
||||
"0047": {
|
||||
"title": ""
|
||||
},
|
||||
"0048": {
|
||||
"title": ""
|
||||
},
|
||||
"0049": {
|
||||
"title": ""
|
||||
},
|
||||
"0050": {
|
||||
"title": ""
|
||||
},
|
||||
"0051": {
|
||||
"title": ""
|
||||
},
|
||||
"0052": {
|
||||
"title": ""
|
||||
},
|
||||
"0053": {
|
||||
"title": ""
|
||||
},
|
||||
"0054": {
|
||||
"title": ""
|
||||
},
|
||||
"0055": {
|
||||
"title": ""
|
||||
},
|
||||
"0056": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,18 +152,28 @@ export class AttributionController implements vscode.Disposable {
|
||||
return;
|
||||
}
|
||||
const s = this.state(docPath);
|
||||
// An undo/redo is history navigation, NOT authorship (#38): reconcile span
|
||||
// geometry but never freshly attribute the re-inserted text to the current
|
||||
// author — otherwise restored baseline text (or reverted Claude text) is
|
||||
// falsely colored human in the preview. A seam edit is always a forward
|
||||
// apply, so undo/redo also bypasses seam matching.
|
||||
const isUndoRedo =
|
||||
e.reason === vscode.TextDocumentChangeReason.Undo ||
|
||||
e.reason === vscode.TextDocumentChangeReason.Redo;
|
||||
// One applyEdit = one change event, but the host may deliver a seam edit
|
||||
// as SEVERAL minimal hunks (word-level diffing). Match the EVENT's net
|
||||
// effect against the registry; on a hit the agent owns its FULL intended
|
||||
// replacement (INV-9) — apply it as ONE algebra edit, not per hunk.
|
||||
const hit = this.pending.matchEvent(
|
||||
docPath,
|
||||
e.contentChanges.map((c) => ({
|
||||
start: c.rangeOffset,
|
||||
end: c.rangeOffset + c.rangeLength,
|
||||
newLength: c.text.length,
|
||||
})),
|
||||
);
|
||||
const hit = isUndoRedo
|
||||
? null
|
||||
: this.pending.matchEvent(
|
||||
docPath,
|
||||
e.contentChanges.map((c) => ({
|
||||
start: c.rangeOffset,
|
||||
end: c.rangeOffset + c.rangeLength,
|
||||
newLength: c.text.length,
|
||||
})),
|
||||
);
|
||||
if (hit) {
|
||||
const full = hit.full ?? { start: hit.start, end: hit.end, newLength: hit.newText.length };
|
||||
s.spans = applyChange(s.spans, full, hit.provenance, {
|
||||
@@ -180,10 +190,16 @@ export class AttributionController implements vscode.Disposable {
|
||||
end: change.rangeOffset + change.rangeLength,
|
||||
newLength: change.text.length,
|
||||
};
|
||||
s.spans = applyChange(s.spans, edit, this.currentAuthor(), {
|
||||
newId: () => newId("at"),
|
||||
now: () => new Date().toISOString(),
|
||||
});
|
||||
s.spans = applyChange(
|
||||
s.spans,
|
||||
edit,
|
||||
this.currentAuthor(),
|
||||
{
|
||||
newId: () => newId("at"),
|
||||
now: () => new Date().toISOString(),
|
||||
},
|
||||
!isUndoRedo,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (s.spans.length > 0) s.hadAttributions = true;
|
||||
|
||||
@@ -49,12 +49,19 @@ export function coalesce(spans: LiveSpan[]): LiveSpan[] {
|
||||
* Apply one document edit (the half-open range [start,end) replaced by
|
||||
* newLength chars) authored by `author`. Existing spans shift/split/clip;
|
||||
* inserted chars become a new span of `author` (INV-7).
|
||||
*
|
||||
* `attributeInserted` (default true) controls whether inserted chars get a new
|
||||
* span. Pass `false` for an UNDO/REDO change (#38): the geometry of existing
|
||||
* spans is still reconciled, but re-inserted text is NOT freshly attributed —
|
||||
* an undo is history navigation, not authorship, so restored text stays neutral
|
||||
* rather than being falsely claimed by the current author.
|
||||
*/
|
||||
export function applyChange(
|
||||
spans: LiveSpan[],
|
||||
edit: TextEdit,
|
||||
author: Provenance,
|
||||
ctx: TrackerCtx,
|
||||
attributeInserted = true,
|
||||
): LiveSpan[] {
|
||||
const delta = edit.newLength - (edit.end - edit.start);
|
||||
const out: LiveSpan[] = [];
|
||||
@@ -71,7 +78,7 @@ export function applyChange(
|
||||
if (right) out.push(left ? { ...right, id: ctx.newId() } : right);
|
||||
}
|
||||
}
|
||||
if (edit.newLength > 0) {
|
||||
if (attributeInserted && edit.newLength > 0) {
|
||||
out.push({
|
||||
id: ctx.newId(),
|
||||
start: edit.start,
|
||||
|
||||
+17
-106
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* DiffViewController — F6 diff-view toggle (spec §6.2/§6.4). Owns the baseline
|
||||
* 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), serves the baseline as a readonly `cowriting-baseline:` virtual
|
||||
* document, and toggles a native vscode.diff (baseline left, the LIVE document
|
||||
* right). A pure view: never mutates the document, sidecar, or attribution
|
||||
* state (INV-19).
|
||||
* 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).
|
||||
*
|
||||
* F6 works on ANY text document, not just workspace files — it needs no
|
||||
* 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
|
||||
* single review surface; only the baseline store survives here (spec §6.7).
|
||||
*
|
||||
* 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
|
||||
@@ -14,40 +17,24 @@
|
||||
* 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 * as path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import * as vscode from "vscode";
|
||||
import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore";
|
||||
|
||||
export const BASELINE_SCHEME = "cowriting-baseline";
|
||||
|
||||
export class DiffViewController implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
/** Source of truth for the content provider, keyed by `document.uri.toString()`. */
|
||||
/** Source of truth for the baseline, keyed by `document.uri.toString()`. */
|
||||
private readonly baselines = new Map<string, Baseline>();
|
||||
private readonly onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
|
||||
/** F7 (additive): fires on every baseline capture (open / advance / pin) so
|
||||
* the track-changes preview refreshes without polling. Mirrors the internal
|
||||
* content-provider change signal but carries the real document URI. */
|
||||
* 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) {
|
||||
const provider: vscode.TextDocumentContentProvider = {
|
||||
onDidChange: this.onDidChangeEmitter.event,
|
||||
provideTextDocumentContent: (uri) => {
|
||||
// The baseline URI carries the real document URI in its query (see baselineUri).
|
||||
return this.baselines.get(uri.query)?.text ?? "";
|
||||
},
|
||||
};
|
||||
this.disposables.push(
|
||||
this.onDidChangeEmitter,
|
||||
this.onDidChangeBaselineEmitter,
|
||||
vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, provider),
|
||||
vscode.commands.registerCommand("cowriting.toggleDiffView", () =>
|
||||
this.toggle(vscode.window.activeTextEditor),
|
||||
),
|
||||
vscode.commands.registerCommand("cowriting.pinDiffBaseline", () =>
|
||||
this.pinCommand(vscode.window.activeTextEditor),
|
||||
),
|
||||
@@ -60,7 +47,7 @@ export class DiffViewController implements vscode.Disposable {
|
||||
|
||||
// ---- diffability / identity --------------------------------------------------------
|
||||
|
||||
/** Any text document F6 can diff: a saved file OR an unsaved buffer. */
|
||||
/** Any text document F6 tracks a baseline for: a saved file OR an unsaved buffer. */
|
||||
private isDiffable(document: vscode.TextDocument): boolean {
|
||||
return document.uri.scheme === "file" || document.uri.scheme === "untitled";
|
||||
}
|
||||
@@ -76,11 +63,6 @@ export class DiffViewController implements vscode.Disposable {
|
||||
private storageKey(uriKey: string): string {
|
||||
return createHash("sha256").update(uriKey).digest("hex");
|
||||
}
|
||||
/** The readonly virtual-doc URI; its query carries the real document URI. */
|
||||
private baselineUri(document: vscode.TextDocument): vscode.Uri {
|
||||
const name = path.basename(document.uri.path) || "untitled";
|
||||
return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: "/" + name, query: this.uriKey(document) });
|
||||
}
|
||||
|
||||
// ---- baseline lifecycle (§6.4) -----------------------------------------------------
|
||||
|
||||
@@ -109,13 +91,13 @@ export class DiffViewController implements vscode.Disposable {
|
||||
this.capture(document, "machine-landing");
|
||||
}
|
||||
|
||||
/** Human pin: baseline := now; the open diff visibly empties (left = right). */
|
||||
/** Human pin: baseline := now; the preview's change-marks empty (left = right). */
|
||||
pin(document: vscode.TextDocument): void {
|
||||
if (!this.isDiffable(document)) return;
|
||||
this.capture(document, "pinned");
|
||||
}
|
||||
|
||||
/** Capture buffer text at this epoch, persist (if persistable), refresh any open diff. */
|
||||
/** Capture buffer text at this epoch, persist (if persistable), notify F7/F10. */
|
||||
private capture(document: vscode.TextDocument, reason: BaselineReason): void {
|
||||
const key = this.uriKey(document);
|
||||
const baseline: Baseline = { uri: key, text: document.getText(), capturedAt: new Date().toISOString(), reason };
|
||||
@@ -127,8 +109,6 @@ export class DiffViewController implements vscode.Disposable {
|
||||
this.warnStorageOnce();
|
||||
}
|
||||
}
|
||||
// An open diff re-requests the left side when its baseline URI changes.
|
||||
this.onDidChangeEmitter.fire(this.baselineUri(document));
|
||||
this.onDidChangeBaselineEmitter.fire({ uri: key });
|
||||
}
|
||||
|
||||
@@ -136,78 +116,13 @@ export class DiffViewController implements vscode.Disposable {
|
||||
if (this.storageWarned) return;
|
||||
this.storageWarned = true;
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: diff-view storage is unavailable — baselines are kept in memory only and won't survive a reload.",
|
||||
"Cowriting: baseline storage is unavailable — baselines are kept in memory only and won't survive a reload.",
|
||||
);
|
||||
}
|
||||
|
||||
// ---- toggle UX (§6.5 PUC-1) --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* If this doc's baseline diff is open → close it and reveal the normal editor;
|
||||
* else open vscode.diff (baseline left, the live document right). Warns only
|
||||
* when there is no active text editor (or a non-diffable scheme).
|
||||
*/
|
||||
private async toggle(editor: vscode.TextEditor | undefined): Promise<void> {
|
||||
if (!editor || !this.isDiffable(editor.document)) {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: focus a text editor to toggle its diff view.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const document = editor.document;
|
||||
const openTab = this.findDiffTab(document.uri);
|
||||
if (openTab) {
|
||||
await vscode.window.tabGroups.close(openTab);
|
||||
await vscode.window.showTextDocument(document, { preview: false });
|
||||
return;
|
||||
}
|
||||
this.ensureBaseline(document);
|
||||
const baseline = this.baselines.get(this.uriKey(document))!;
|
||||
const name = path.basename(document.uri.path) || "untitled";
|
||||
const unsaved = this.isPersistable(document) ? "" : " (unsaved)";
|
||||
const title = `${name} — my changes since ${this.epochLabel(baseline)}${unsaved}`;
|
||||
await vscode.commands.executeCommand(
|
||||
"vscode.diff",
|
||||
this.baselineUri(document),
|
||||
document.uri,
|
||||
title,
|
||||
{ preview: false },
|
||||
);
|
||||
}
|
||||
|
||||
/** The open baseline-diff tab for this document, if any. */
|
||||
private findDiffTab(modified: vscode.Uri): vscode.Tab | undefined {
|
||||
for (const group of vscode.window.tabGroups.all) {
|
||||
for (const tab of group.tabs) {
|
||||
const input = tab.input;
|
||||
if (
|
||||
input instanceof vscode.TabInputTextDiff &&
|
||||
input.original.scheme === BASELINE_SCHEME &&
|
||||
input.modified.toString() === modified.toString()
|
||||
) {
|
||||
return tab;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Human-readable epoch for the diff tab title (§5 / §6.5). */
|
||||
private epochLabel(baseline: Baseline): string {
|
||||
const time = new Date(baseline.capturedAt).toLocaleTimeString();
|
||||
switch (baseline.reason) {
|
||||
case "opened":
|
||||
return `opened ${time}`;
|
||||
case "machine-landing":
|
||||
return `Claude landed ${time}`;
|
||||
case "pinned":
|
||||
return `pinned ${time}`;
|
||||
}
|
||||
}
|
||||
|
||||
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 diff baseline.");
|
||||
void vscode.window.showWarningMessage("Cowriting: focus a text editor to pin its review baseline.");
|
||||
return;
|
||||
}
|
||||
this.pin(editor.document);
|
||||
@@ -219,10 +134,6 @@ export class DiffViewController implements vscode.Disposable {
|
||||
const b = this.baselines.get(uriString);
|
||||
return b ? { text: b.text, reason: b.reason, capturedAt: b.capturedAt } : undefined;
|
||||
}
|
||||
/** Is this doc's baseline diff currently open in any tab group? */
|
||||
isDiffOpen(uriString: string): boolean {
|
||||
return this.findDiffTab(vscode.Uri.parse(uriString)) !== undefined;
|
||||
}
|
||||
/** Absolute on-disk path of this doc's persisted baseline, or undefined (untitled/in-memory). */
|
||||
baselineFilePath(uriString: string): string | undefined {
|
||||
if (!this.store || vscode.Uri.parse(uriString).scheme !== "file") return undefined;
|
||||
|
||||
+52
-10
@@ -11,6 +11,7 @@ import { GlobalSidecarStore } from "./globalSidecarStore";
|
||||
import { SidecarRouter } from "./sidecarRouter";
|
||||
import { DiffViewController } from "./diffViewController";
|
||||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||||
import { LiveProgressUi } from "./liveProgressUi";
|
||||
import { isAuthorable, selectionRejection } from "./workspacePath";
|
||||
|
||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||
@@ -23,12 +24,18 @@ export interface CowritingApi {
|
||||
diffViewController: DiffViewController;
|
||||
trackChangesPreviewController: TrackChangesPreviewController;
|
||||
sidecarRouter: SidecarRouter;
|
||||
liveProgressUi: LiveProgressUi;
|
||||
}
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||
// --- POC command (Feature #2), unchanged ---
|
||||
const output = vscode.window.createOutputChannel(CHANNEL_NAME);
|
||||
context.subscriptions.push(output);
|
||||
|
||||
// #60: shared live-progress UI (notification activity line + "Cowriting: Claude"
|
||||
// OutputChannel) for both Ask-Claude entry points.
|
||||
const liveProgressUi = new LiveProgressUi();
|
||||
context.subscriptions.push(liveProgressUi);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
|
||||
try {
|
||||
@@ -50,13 +57,14 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
}),
|
||||
);
|
||||
|
||||
// --- F6: diff-view toggle (Features #17 + #19) — workspace-INDEPENDENT ---
|
||||
// F6 works on ANY diffable doc (file or untitled), so it is constructed
|
||||
// regardless of whether a folder is open, and its commands are always live
|
||||
// (never stubbed). Baseline lives in VS Code's per-extension GLOBAL storage
|
||||
// (always present), never the repo (INV-19). The machine-landing advance is
|
||||
// wired in the with-root branch below (landings only occur on workspace files
|
||||
// through the seam). The controller self-wires baseline capture on open.
|
||||
// --- 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.
|
||||
const baselineStorageDir = context.globalStorageUri?.fsPath;
|
||||
const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null;
|
||||
const diffViewController = new DiffViewController(baselineStore);
|
||||
@@ -103,9 +111,24 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
context.extensionUri,
|
||||
attributionController,
|
||||
proposalController,
|
||||
liveProgressUi,
|
||||
);
|
||||
context.subscriptions.push(trackChangesPreviewController);
|
||||
|
||||
// #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.
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => {
|
||||
const doc = vscode.window.activeTextEditor?.document;
|
||||
if (!doc || doc.languageId !== "markdown") {
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals.");
|
||||
return;
|
||||
}
|
||||
await trackChangesPreviewController.acceptAll(doc);
|
||||
}),
|
||||
);
|
||||
|
||||
// --- F6 machine-landing wiring — now for ANY authorable doc ---
|
||||
// The seam's single machine-landing signal advances the F6 baseline (INV-18);
|
||||
// the seam can now fire on out-of-folder files too, so wire it unconditionally.
|
||||
@@ -215,10 +238,28 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
const turnId = `turn-${Date.now().toString(36)}`;
|
||||
try {
|
||||
await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
|
||||
async () => {
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: "Cowriting: asking Claude…",
|
||||
cancellable: true,
|
||||
},
|
||||
async (progress, token) => {
|
||||
const { runEditTurn } = await import("./liveTurn");
|
||||
const turn = await runEditTurn(instruction, selectedText);
|
||||
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.",
|
||||
@@ -277,6 +318,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
diffViewController,
|
||||
trackChangesPreviewController,
|
||||
sidecarRouter,
|
||||
liveProgressUi,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* liveProgressUi.ts — host-side relay of TurnProgress snapshots to VS Code UI
|
||||
* (#60, spec coauthoring-live-progress.md §3.4). The ONLY surfaces are the
|
||||
* existing withProgress notification (an activity line) and a dedicated
|
||||
* OutputChannel streaming the full assistant text — no new network/webview
|
||||
* surface (INV-45). vscode-only; all pure logic lives in turnProgress.ts.
|
||||
*/
|
||||
import * as vscode from "vscode";
|
||||
import { formatProgressLine, type TurnProgressSnapshot } from "./turnProgress";
|
||||
|
||||
const CHANNEL_NAME = "Cowriting: Claude";
|
||||
|
||||
export interface TurnUi {
|
||||
/** Pass to runEditTurn's opts.onProgress. */
|
||||
onProgress: (snapshot: TurnProgressSnapshot) => void;
|
||||
/** Pass to runEditTurn's opts.signal — fired when the user cancels the notification. */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export class LiveProgressUi {
|
||||
readonly channel: vscode.OutputChannel;
|
||||
|
||||
constructor() {
|
||||
this.channel = vscode.window.createOutputChannel(CHANNEL_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin one turn's UI. Writes the per-turn header to the channel and returns
|
||||
* the onProgress relay + an AbortSignal linked to the notification's cancel
|
||||
* token. The channel APPENDS (it doubles as a debug log of recent turns, spec
|
||||
* §3.5); it auto-reveals (without stealing focus) on the first streamed text,
|
||||
* gated by `cowriting.liveProgress.revealOutput`.
|
||||
*/
|
||||
begin(
|
||||
instruction: string,
|
||||
progress: vscode.Progress<{ message?: string }>,
|
||||
token: vscode.CancellationToken,
|
||||
): TurnUi {
|
||||
const controller = new AbortController();
|
||||
token.onCancellationRequested(() => controller.abort());
|
||||
this.channel.appendLine(`── asking: ${instruction} ──`);
|
||||
|
||||
let revealed = false;
|
||||
const reveal = (): void => {
|
||||
if (revealed) return;
|
||||
revealed = true;
|
||||
const cfg = vscode.workspace.getConfiguration("cowriting");
|
||||
if (cfg.get<boolean>("liveProgress.revealOutput", true)) this.channel.show(true);
|
||||
};
|
||||
|
||||
const onProgress = (s: TurnProgressSnapshot): void => {
|
||||
progress.report({ message: formatProgressLine(s) });
|
||||
if (s.textDelta) {
|
||||
this.channel.append(s.textDelta);
|
||||
reveal();
|
||||
}
|
||||
};
|
||||
|
||||
return { onProgress, signal: controller.signal };
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.channel.dispose();
|
||||
}
|
||||
}
|
||||
+61
-11
@@ -9,6 +9,9 @@
|
||||
* never bundled (esbuild keeps it external).
|
||||
*/
|
||||
|
||||
import type { TurnProgressSnapshot } from "./turnProgress";
|
||||
import { createTurnProgressState, reduceTurnProgress } from "./turnProgress";
|
||||
|
||||
export interface EditTurnResult {
|
||||
replacement: string;
|
||||
model: string;
|
||||
@@ -16,6 +19,19 @@ export interface EditTurnResult {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for runEditTurn. Both new fields are purely additive observability /
|
||||
* control over the existing turn (INV-44): `onProgress` streams reduced progress
|
||||
* snapshots out; `signal` cancels the turn in. Neither touches the result path,
|
||||
* and neither pulls `vscode` into this module (INV-43 — `AbortSignal` is a web
|
||||
* standard, the progress snapshot is a domain type).
|
||||
*/
|
||||
export interface RunEditTurnOptions {
|
||||
modelId?: string;
|
||||
onProgress?: (snapshot: TurnProgressSnapshot) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
"You are a precise text editor embedded in VS Code.",
|
||||
"You will be given a piece of text and an instruction.",
|
||||
@@ -40,7 +56,7 @@ export function extractReplacement(outputText: string, selectedText: string): st
|
||||
export async function runEditTurn(
|
||||
instruction: string,
|
||||
selectedText: string,
|
||||
opts?: { modelId?: string },
|
||||
opts?: RunEditTurnOptions,
|
||||
): Promise<EditTurnResult> {
|
||||
const sdk = await import("@cline/sdk");
|
||||
const modelId = opts?.modelId ?? "sonnet";
|
||||
@@ -49,16 +65,50 @@ export async function runEditTurn(
|
||||
modelId,
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
});
|
||||
const result = await agent.run(
|
||||
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
|
||||
);
|
||||
// The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed"
|
||||
// (@cline/shared agent.d.ts) — "completed" is the success status.
|
||||
if (result.status !== "completed") {
|
||||
throw new Error(
|
||||
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
|
||||
"(is Claude Code installed and signed in?)",
|
||||
|
||||
// Stream reduced progress snapshots out (INV-44 additive) and wire cancellation
|
||||
// in via the AbortSignal (INV-47). agent.subscribe returns its unsubscribe fn.
|
||||
let state = createTurnProgressState();
|
||||
const unsubscribe = opts?.onProgress
|
||||
? agent.subscribe((event) => {
|
||||
const next = reduceTurnProgress(state, event);
|
||||
state = next.state;
|
||||
// Observability must never affect the result (INV-44): a throwing relay
|
||||
// is swallowed, not allowed to propagate into the SDK and fail the turn.
|
||||
if (next.snapshot) {
|
||||
try {
|
||||
opts.onProgress!(next.snapshot);
|
||||
} catch {
|
||||
/* progress is best-effort */
|
||||
}
|
||||
}
|
||||
})
|
||||
: undefined;
|
||||
const onAbort = () => agent.abort();
|
||||
opts?.signal?.addEventListener("abort", onAbort);
|
||||
|
||||
try {
|
||||
// A signal already aborted before the turn starts can't be honored by
|
||||
// agent.abort() (the SDK's AbortController isn't created until run()), so
|
||||
// short-circuit to the same aborted outcome the call site reflects (INV-47).
|
||||
if (opts?.signal?.aborted) {
|
||||
throw new Error("claude-code turn aborted: cancelled before start");
|
||||
}
|
||||
const result = await agent.run(
|
||||
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
|
||||
);
|
||||
// The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed"
|
||||
// (@cline/shared agent.d.ts) — "completed" is the success status. An aborted
|
||||
// turn (user cancel) falls into this throw; the call site reflects "cancelled".
|
||||
if (result.status !== "completed") {
|
||||
throw new Error(
|
||||
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
|
||||
"(is Claude Code installed and signed in?)",
|
||||
);
|
||||
}
|
||||
return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId };
|
||||
} finally {
|
||||
unsubscribe?.();
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId };
|
||||
}
|
||||
|
||||
@@ -90,6 +90,13 @@ export interface Proposal {
|
||||
turnId?: string;
|
||||
/** what the human asked for (review context). */
|
||||
instruction?: string;
|
||||
/**
|
||||
* F12/#47 (INV-39/40): the review-decision unit this proposal represents.
|
||||
* `"block"` ⇒ the anchor spans a whole document block and accept reconciles
|
||||
* attribution per WORD inside it (INV-40); `"single"` (or absent, for
|
||||
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
|
||||
*/
|
||||
granularity?: "block" | "single";
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
|
||||
+93
-17
@@ -18,7 +18,7 @@ import { addProposal, removeProposal } from "./proposalModel";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import type { VersionGuard } from "./versionGuard";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { ProposalView } from "./trackChangesModel";
|
||||
import { wordEditHunks, type ProposalView } from "./trackChangesModel";
|
||||
|
||||
/** Test-facing snapshot of what is currently rendered for a document. */
|
||||
export interface RenderedProposal {
|
||||
@@ -122,7 +122,7 @@ export class ProposalController implements vscode.Disposable {
|
||||
fp: Fingerprint,
|
||||
replacement: string,
|
||||
author: Provenance,
|
||||
opts?: { turnId?: string; instruction?: string },
|
||||
opts?: { turnId?: string; instruction?: string; granularity?: "block" | "single" },
|
||||
): Promise<string | undefined> {
|
||||
if (!this.isTracked(document)) return undefined;
|
||||
if (this.guard.isReadOnly(this.keyOf(document))) return undefined;
|
||||
@@ -138,9 +138,40 @@ 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). */
|
||||
async acceptById(docPath: string, proposalId: string): Promise<boolean> {
|
||||
async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
|
||||
const hit = this.byId(docPath, proposalId);
|
||||
return hit ? this.accept(hit.state, hit.proposal) : false;
|
||||
return hit ? this.accept(hit.state, hit.proposal, opts) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* #46 (INV-42): accept EVERY pending proposal on a document in one gesture — a
|
||||
* batched application of the existing `acceptById` seam, not a new mechanism.
|
||||
* Block proposals take the INV-40 word-precise path automatically. Applied in
|
||||
* DESCENDING anchor order so an earlier accept never invalidates a later one's
|
||||
* offsets; proposals whose anchor can't resolve are SKIPPED (never force-applied)
|
||||
* and counted. Returns the applied-vs-skipped tally for the caller to report.
|
||||
*/
|
||||
async acceptAllProposals(document: vscode.TextDocument): Promise<{ applied: number; skipped: number }> {
|
||||
if (!this.isTracked(document)) return { applied: 0, skipped: 0 };
|
||||
const state = this.ensureState(document);
|
||||
state.artifact = this.store.load(state.docPath) ?? emptyArtifact(state.docPath);
|
||||
const text = document.getText();
|
||||
const items = state.artifact.proposals.map((p) => {
|
||||
const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
|
||||
const resolved = fp ? resolve(text, fp) : "orphaned";
|
||||
return { id: p.id, start: resolved === "orphaned" ? null : resolved.start };
|
||||
});
|
||||
const resolvable = items
|
||||
.filter((i): i is { id: string; start: number } => i.start !== null)
|
||||
.sort((a, b) => b.start - a.start);
|
||||
let applied = 0;
|
||||
let skipped = items.length - resolvable.length; // orphans, skipped up front
|
||||
for (const it of resolvable) {
|
||||
// silent: one batch report stands in for N per-proposal warnings.
|
||||
if (await this.acceptById(state.docPath, it.id, { silent: true })) applied++;
|
||||
else skipped++;
|
||||
}
|
||||
return { applied, skipped };
|
||||
}
|
||||
/** Reject by proposal id (test-facing twin of the thread-menu gesture). */
|
||||
rejectById(docPath: string, proposalId: string): boolean {
|
||||
@@ -150,7 +181,7 @@ export class ProposalController implements vscode.Disposable {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async accept(state: DocState, proposal: Proposal): Promise<boolean> {
|
||||
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);
|
||||
if (!document) return false;
|
||||
@@ -158,22 +189,32 @@ export class ProposalController implements vscode.Disposable {
|
||||
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
|
||||
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
|
||||
if (resolved === "orphaned") {
|
||||
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).",
|
||||
);
|
||||
// #46: accept-all suppresses per-proposal warnings (one batch report instead).
|
||||
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).",
|
||||
);
|
||||
this.renderAll(document);
|
||||
return false;
|
||||
}
|
||||
const range = new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end));
|
||||
// No awaits between resolve and the seam call: document.version is current.
|
||||
const ok = await this.attribution.applyAgentEdit(document, range, proposal.replacement, proposal.author, {
|
||||
expectedVersion: document.version,
|
||||
turnId: proposal.turnId,
|
||||
});
|
||||
// #47 (INV-40): a BLOCK proposal applies the whole block but attributes only
|
||||
// the words Claude actually changed; a single proposal applies its whole range.
|
||||
const ok =
|
||||
proposal.granularity === "block"
|
||||
? await this.acceptBlock(document, resolved, proposal)
|
||||
: await this.attribution.applyAgentEdit(
|
||||
document,
|
||||
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
|
||||
proposal.replacement,
|
||||
proposal.author,
|
||||
// No awaits between resolve and the seam call: document.version is current.
|
||||
{ expectedVersion: document.version, turnId: proposal.turnId },
|
||||
);
|
||||
if (!ok) {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: the editor rejected the accept — the proposal is still pending.",
|
||||
);
|
||||
if (!opts?.silent)
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: the editor rejected the accept — the proposal is still pending.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
|
||||
@@ -181,6 +222,41 @@ export class ProposalController implements vscode.Disposable {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* #47 (INV-40): accept a BLOCK proposal — apply Claude's whole block, but
|
||||
* attribute ONLY the runs Claude actually changed. The block is the DECISION
|
||||
* unit; the word is the ATTRIBUTION unit. An intra-block word sub-diff
|
||||
* (`wordEditHunks`, the raw engine INV-37 used, repurposed — un-anchored so the
|
||||
* runs stay disjoint for a batch apply) yields the changed runs; each lands
|
||||
* through the F4 seam (Claude-attributed), applied last-position-first so an
|
||||
* earlier run's offsets stay valid under the later ones. Unchanged spans within
|
||||
* the block are never touched, so their prior authorship stands. (Each run is
|
||||
* one seam edit / one undo step — see the spec's deferred note on undo grouping.)
|
||||
*/
|
||||
private async acceptBlock(
|
||||
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; // block already equals the proposal — nothing to attribute
|
||||
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,
|
||||
});
|
||||
if (!ok) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private reject(state: DocState, proposal: Proposal): void {
|
||||
if (this.guard.isReadOnly(state.docPath)) return;
|
||||
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
|
||||
|
||||
@@ -12,7 +12,7 @@ export function addProposal(
|
||||
fp: Fingerprint,
|
||||
replacement: string,
|
||||
author: Provenance,
|
||||
opts?: { turnId?: string; instruction?: string },
|
||||
opts?: { turnId?: string; instruction?: string; granularity?: "block" | "single" },
|
||||
): { proposalId: string; anchorId: string } {
|
||||
const anchorId = newId("a");
|
||||
const proposalId = newId("pr");
|
||||
@@ -25,6 +25,7 @@ export function addProposal(
|
||||
createdAt: new Date().toISOString(),
|
||||
...(opts?.turnId !== undefined ? { turnId: opts.turnId } : {}),
|
||||
...(opts?.instruction !== undefined ? { instruction: opts.instruction } : {}),
|
||||
...(opts?.granularity !== undefined ? { granularity: opts.granularity } : {}),
|
||||
});
|
||||
return { proposalId, anchorId };
|
||||
}
|
||||
|
||||
+347
-22
@@ -9,7 +9,7 @@
|
||||
* DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview.
|
||||
*/
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { diffArrays, diffWords } from "diff";
|
||||
import { diffArrays, diffWords, diffWordsWithSpace } from "diff";
|
||||
import { diffMermaid } from "./mermaidDiff";
|
||||
|
||||
export type BlockType = "prose" | "code" | "mermaid";
|
||||
@@ -193,6 +193,210 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[]
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** A contiguous changed region of `currentText` and its replacement (F11). */
|
||||
export interface EditHunk {
|
||||
/** char offset of the hunk's first changed char in currentText. */
|
||||
start: number;
|
||||
/** char offset one past the hunk's last changed char (start==end → a pure insertion). */
|
||||
end: number;
|
||||
/** the text that replaces currentText.slice(start, end). */
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* F11 (INV-37, §6.4): diff a whole-document rewrite into per-hunk replacement
|
||||
* ranges — each becomes one independent F4 single-range proposal (so a document
|
||||
* edit surfaces as N independently ✓/✗-able blue blocks, no new model). Pure,
|
||||
* vscode-free, deterministic. Offsets index into `currentText`: removed +
|
||||
* unchanged parts reconstruct it exactly, so advancing on those yields true
|
||||
* source offsets. Adjacent added/removed runs coalesce into one hunk; an
|
||||
* unchanged part flushes the open hunk.
|
||||
*
|
||||
* A PURE insertion (added text between two unchanged regions) would otherwise be
|
||||
* a zero-width hunk (start==end) — and F4's fingerprint of empty text can never
|
||||
* resolve (anchorer.resolve orphans an empty needle), so the proposal could
|
||||
* never be accepted. Every zero-width hunk is therefore anchored to an adjacent
|
||||
* source token (`anchorInsertion`): its range absorbs that token and its
|
||||
* replacement keeps it, so the net text is identical but `fp.text` is non-empty
|
||||
* and resolvable. A zero-width hunk is always bordered by unchanged text (or a
|
||||
* doc edge), so the absorbed token is genuinely present in `currentText` and is
|
||||
* never part of another hunk.
|
||||
*/
|
||||
export function diffToHunks(currentText: string, rewrittenText: string): EditHunk[] {
|
||||
return wordEditHunks(currentText, rewrittenText).map((h) =>
|
||||
h.start === h.end ? anchorInsertion(h, currentText) : h,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw word-level diff underlying `diffToHunks`: disjoint, ordered EditHunks
|
||||
* that exactly partition the change (a pure insertion stays ZERO-WIDTH at its
|
||||
* offset — not anchored). `diffToHunks` adds anchoring on top so each hunk's F4
|
||||
* fingerprint resolves; callers that apply hunks directly through the seam
|
||||
* (INV-40's intra-block accept) want THESE raw, non-overlapping hunks instead —
|
||||
* anchoring can grow an insertion to absorb a token an adjacent hunk also edits,
|
||||
* which corrupts a batch apply. Pure, vscode-free, deterministic. Applying these
|
||||
* right→left reconstructs `rewrittenText` exactly.
|
||||
*/
|
||||
export function wordEditHunks(currentText: string, rewrittenText: string): EditHunk[] {
|
||||
const hunks: EditHunk[] = [];
|
||||
let offset = 0;
|
||||
let open: EditHunk | null = null;
|
||||
const flush = () => {
|
||||
if (open) hunks.push(open);
|
||||
open = null;
|
||||
};
|
||||
for (const part of diffWordsWithSpace(currentText, rewrittenText)) {
|
||||
if (part.added) {
|
||||
open ??= { start: offset, end: offset, replacement: "" };
|
||||
open.replacement += part.value;
|
||||
} else if (part.removed) {
|
||||
open ??= { start: offset, end: offset, replacement: "" };
|
||||
offset += part.value.length;
|
||||
open.end = offset;
|
||||
} else {
|
||||
flush();
|
||||
offset += part.value.length;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return hunks;
|
||||
}
|
||||
|
||||
const isWs = (c: string): boolean => /\s/.test(c);
|
||||
|
||||
/**
|
||||
* Grow a zero-width insertion hunk to absorb an adjacent source token so its F4
|
||||
* fingerprint has real text to resolve. Prefer the FOLLOWING token (optional
|
||||
* leading whitespace + a run of non-whitespace); at end-of-document, fall back to
|
||||
* the PRECEDING token. The absorbed text is kept in the replacement, so the
|
||||
* applied result is unchanged.
|
||||
*/
|
||||
function anchorInsertion(hunk: EditHunk, text: string): EditHunk {
|
||||
const p = hunk.start;
|
||||
if (p < text.length) {
|
||||
let e = p;
|
||||
while (e < text.length && isWs(text[e])) e++;
|
||||
while (e < text.length && !isWs(text[e])) e++;
|
||||
if (e === p) e = Math.min(text.length, p + 1);
|
||||
return { start: p, end: e, replacement: hunk.replacement + text.slice(p, e) };
|
||||
}
|
||||
let s = p;
|
||||
while (s > 0 && isWs(text[s - 1])) s--;
|
||||
while (s > 0 && !isWs(text[s - 1])) s--;
|
||||
if (s === p) s = Math.max(0, p - 1);
|
||||
return { start: s, end: p, replacement: text.slice(s, p) + hunk.replacement };
|
||||
}
|
||||
|
||||
type BlockAlignKind = "unchanged" | "changed" | "removed" | "added";
|
||||
interface BlockAlignOp {
|
||||
kind: BlockAlignKind;
|
||||
ci?: number; // index into the current blocks
|
||||
ni?: number; // index into the rewritten blocks
|
||||
}
|
||||
|
||||
/**
|
||||
* Align two block sequences by normalized key (jsdiff `diffArrays`), pairing an
|
||||
* adjacent removed-then-added run element-wise into `changed` ops (the surplus
|
||||
* staying `removed` / `added`) — the same pairing `diffBlocks` uses, but yielding
|
||||
* index ops so the caller can read each side's source range. Pure.
|
||||
*/
|
||||
function alignBlocks(cur: BlockWithRange[], next: BlockWithRange[]): BlockAlignOp[] {
|
||||
const changes = diffArrays(
|
||||
cur.map((b) => b.key),
|
||||
next.map((b) => b.key),
|
||||
);
|
||||
const ops: BlockAlignOp[] = [];
|
||||
let bi = 0; // current index
|
||||
let ci = 0; // rewritten index
|
||||
for (let n = 0; n < changes.length; n++) {
|
||||
const ch = changes[n];
|
||||
const count = ch.count ?? ch.value.length;
|
||||
if (!ch.added && !ch.removed) {
|
||||
for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", ci: bi++, ni: ci++ });
|
||||
continue;
|
||||
}
|
||||
if (ch.removed) {
|
||||
const nx = changes[n + 1];
|
||||
const addCount = nx?.added ? (nx.count ?? nx.value.length) : 0;
|
||||
const paired = Math.min(count, addCount);
|
||||
for (let k = 0; k < paired; k++) ops.push({ kind: "changed", ci: bi++, ni: ci++ });
|
||||
for (let k = paired; k < count; k++) ops.push({ kind: "removed", ci: bi++ });
|
||||
for (let k = paired; k < addCount; k++) ops.push({ kind: "added", ni: ci++ });
|
||||
if (nx?.added) n++;
|
||||
continue;
|
||||
}
|
||||
for (let k = 0; k < count; k++) ops.push({ kind: "added", ni: ci++ });
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
/**
|
||||
* #47 (INV-39, §6.4): diff a whole-document rewrite into ONE EditHunk per CHANGED
|
||||
* BLOCK — the unit a human reviews — rather than per word (which INV-37/`diffToHunks`
|
||||
* did and this supersedes for document edits). Built by aligning both sides into
|
||||
* the existing block units (`splitBlocksWithRanges`) and keying with the same diff
|
||||
* as `diffBlocks`:
|
||||
* - a CHANGED block (1:1 aligned, even when adjacent to other changed blocks) → a
|
||||
* block-aligned hunk `[block.start, block.end)` → the rewritten block's raw
|
||||
* text. A code/mermaid fence is one such whole-block hunk (atomic, INV-23). So
|
||||
* a copy-edit pass touching N consecutive paragraphs yields N proposals.
|
||||
* - a run of block INSERTIONS / DELETIONS → one gap-span hunk covering the source
|
||||
* between the bounding aligned blocks (unchanged OR changed — both are 1:1
|
||||
* anchors) → the matching rewritten span (separators included), so
|
||||
* reconstruction stays exact. A zero-width gap-span (insert at a seamless
|
||||
* boundary) is anchored to an adjacent token (INV-41) so its F4 fingerprint
|
||||
* resolves and it accepts.
|
||||
* - unchanged blocks (same key AND same raw) → no hunk.
|
||||
* Pure, vscode-free, deterministic; same `EditHunk` shape as `diffToHunks` (which
|
||||
* is RETAINED as the intra-block sub-diff engine for word-precise accept
|
||||
* attribution — INV-40). Applying all hunks right→left reconstructs `rewrittenText`
|
||||
* exactly.
|
||||
*/
|
||||
export function diffToBlockHunks(currentText: string, rewrittenText: string): EditHunk[] {
|
||||
const cur = splitBlocksWithRanges(currentText);
|
||||
const next = splitBlocksWithRanges(rewrittenText);
|
||||
const ops = alignBlocks(cur, next);
|
||||
// Same key but different raw (whitespace/case) is still a real content change.
|
||||
for (const op of ops) {
|
||||
if (op.kind === "unchanged" && cur[op.ci!].raw !== next[op.ni!].raw) op.kind = "changed";
|
||||
}
|
||||
// `changed` and `unchanged` are 1:1 anchors (both sides' offsets are known);
|
||||
// `added`/`removed` have no counterpart and must be spanned together.
|
||||
const isAnchor = (op: BlockAlignOp) => op.kind === "unchanged" || op.kind === "changed";
|
||||
const hunks: EditHunk[] = [];
|
||||
let i = 0;
|
||||
while (i < ops.length) {
|
||||
const op = ops[i];
|
||||
if (op.kind === "unchanged") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (op.kind === "changed") {
|
||||
const c = cur[op.ci!];
|
||||
hunks.push({ start: c.start, end: c.end, replacement: next[op.ni!].raw });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// A maximal run of added/removed blocks → one gap-span hunk over the source
|
||||
// between the bounding anchors (or the document edges), replaced with the
|
||||
// matching rewritten span, so the separators reconstruct exactly.
|
||||
let j = i;
|
||||
while (j < ops.length && !isAnchor(ops[j])) j++;
|
||||
const prev = i > 0 ? ops[i - 1] : null; // an anchor by construction
|
||||
const after = j < ops.length ? ops[j] : null; // an anchor (or null at EOF)
|
||||
const curStart = prev ? cur[prev.ci!].end : 0;
|
||||
const curEnd = after ? cur[after.ci!].start : currentText.length;
|
||||
const newStart = prev ? next[prev.ni!].end : 0;
|
||||
const newEnd = after ? next[after.ni!].start : rewrittenText.length;
|
||||
const hunk: EditHunk = { start: curStart, end: curEnd, replacement: rewrittenText.slice(newStart, newEnd) };
|
||||
hunks.push(hunk.start === hunk.end ? anchorInsertion(hunk, currentText) : hunk);
|
||||
i = j;
|
||||
}
|
||||
hunks.sort((a, b) => a.start - b.start);
|
||||
return hunks;
|
||||
}
|
||||
|
||||
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
||||
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
|
||||
// other fences fall through to markdown-it's default (escaped <pre><code>).
|
||||
@@ -237,6 +441,14 @@ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
|
||||
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`.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
function defaultRender(src: string): string {
|
||||
@@ -247,7 +459,17 @@ function chip(message: string): string {
|
||||
return `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
|
||||
}
|
||||
|
||||
function renderOp(op: BlockOp, render: (src: string) => string): string {
|
||||
/**
|
||||
* F11 (INV-36): the source-range attributes a live block's wrapping element
|
||||
* carries, so a preview selection can map back to a source markdown range. `""`
|
||||
* when the block has no live source (a baseline-only deletion / a proposal
|
||||
* block) — those are skipped by the selection mapper. Pure + deterministic.
|
||||
*/
|
||||
function srcAttr(blk: { start: number; end: number } | undefined): string {
|
||||
return blk ? ` data-src-start="${blk.start}" data-src-end="${blk.end}"` : "";
|
||||
}
|
||||
|
||||
function renderOp(op: BlockOp, render: (src: string) => string, src = ""): string {
|
||||
const safe = (src: string): string => {
|
||||
try {
|
||||
return render(src);
|
||||
@@ -293,7 +515,7 @@ function renderOp(op: BlockOp, render: (src: string) => string): string {
|
||||
}
|
||||
break;
|
||||
}
|
||||
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
|
||||
return `<div class="cw-blk ${cls}"${src}>${badge}${inner}</div>`;
|
||||
}
|
||||
|
||||
export type AuthorKind = "claude" | "human";
|
||||
@@ -323,13 +545,33 @@ function authorBadge(authors: Set<AuthorKind>): { cls: string; label: string } |
|
||||
: { cls: "cw-by-human", label: "You" };
|
||||
}
|
||||
|
||||
// Markdown emphasis / code delimiters whose RUNS must never be split by an
|
||||
// injected sentinel — a sentinel between two run chars (e.g. `*|*`) breaks
|
||||
// markdown-it's delimiter pairing (#33 CASE1).
|
||||
const isDelimChar = (c: string): boolean => c === "*" || c === "_" || c === "~" || c === "`";
|
||||
|
||||
/**
|
||||
* #33 (CASE1): a sentinel must not land STRICTLY INSIDE a delimiter run. If `at`
|
||||
* sits between two identical delimiter chars, snap it to the run's start (a
|
||||
* position outside the run) so the run stays intact. Delimiters are invisible once
|
||||
* rendered, so snapping only shifts the colored boundary across markup, never over
|
||||
* visible text.
|
||||
*/
|
||||
function clampOffDelimiterRun(raw: string, at: number): number {
|
||||
if (at <= 0 || at >= raw.length) return at;
|
||||
if (!(isDelimChar(raw[at]) && raw[at - 1] === raw[at])) return at;
|
||||
let p = at;
|
||||
while (p > 0 && raw[p - 1] === raw[at]) p--;
|
||||
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 {
|
||||
const inserts: { at: number; marker: string }[] = [];
|
||||
for (const s of spans) {
|
||||
const lo = Math.max(0, s.start - blockStart);
|
||||
const hi = Math.min(raw.length, s.end - blockStart);
|
||||
if (hi <= lo) continue;
|
||||
const lo = 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 });
|
||||
}
|
||||
@@ -342,13 +584,69 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Replace the rendered sentinels with author <span> tags. */
|
||||
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",
|
||||
);
|
||||
|
||||
/**
|
||||
* #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
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
function sentinelsToSpans(html: string): string {
|
||||
return html
|
||||
.split(SENT.claude.open).join('<span class="cw-by-claude">')
|
||||
.split(SENT.claude.close).join("</span>")
|
||||
.split(SENT.human.open).join('<span class="cw-by-human">')
|
||||
.split(SENT.human.close).join("</span>");
|
||||
const out: string[] = [];
|
||||
let current: AuthorKind | null = null; // which author region we're inside
|
||||
let spanOpen = false; // whether a <span> is currently open in `out`
|
||||
const openSpan = () => {
|
||||
if (current && !spanOpen) {
|
||||
out.push(`<span class="cw-by-${current}">`);
|
||||
spanOpen = true;
|
||||
}
|
||||
};
|
||||
const closeSpan = () => {
|
||||
if (spanOpen) {
|
||||
out.push("</span>");
|
||||
spanOpen = false;
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < html.length; i++) {
|
||||
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
|
||||
else {
|
||||
closeSpan();
|
||||
current = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "<") {
|
||||
// An HTML tag: never let an author span straddle it (text `<` is escaped to
|
||||
// <, so a raw `<` is always a real tag). Copy the tag verbatim, sentinel-free.
|
||||
closeSpan();
|
||||
const gt = html.indexOf(">", i);
|
||||
const end = gt === -1 ? html.length - 1 : gt;
|
||||
out.push(html.slice(i, end + 1).replace(ALL_SENTINELS, ""));
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
openSpan();
|
||||
out.push(ch);
|
||||
}
|
||||
closeSpan();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -367,14 +665,31 @@ export function colorByAuthor(
|
||||
return sentinelsToSpans(render(injected));
|
||||
}
|
||||
|
||||
/** Off-state body: the current buffer as plain markdown, no annotations (INV-33). */
|
||||
/**
|
||||
* Off-state body: the current buffer as plain markdown, no annotations (INV-33).
|
||||
* Each block is wrapped in a bare `<div data-src-start/end>` so the off-mode
|
||||
* preview is still a selection→source mapping surface (INV-36) while staying
|
||||
* visually clean — no `cw-` annotation classes. A doc with no blocks (empty /
|
||||
* whitespace-only) renders whole. Pure + deterministic.
|
||||
*
|
||||
* Tradeoff of the locked block-level mapping (§6.7): rendering PER BLOCK (so each
|
||||
* carries its offsets) means a markdown construct split across blank-line-
|
||||
* separated blocks — a reference-link use and its definition — doesn't resolve
|
||||
* across blocks. `renderReview` already rendered per-block; this keeps the two
|
||||
* modes consistent rather than faithful-but-different. Characterized in tests.
|
||||
*/
|
||||
export function renderPlain(currentText: string, opts: RenderOptions = {}): string {
|
||||
const render = opts.render ?? defaultRender;
|
||||
try {
|
||||
return render(currentText);
|
||||
} catch (err) {
|
||||
return chip(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
const safe = (src: string): string => {
|
||||
try {
|
||||
return render(src);
|
||||
} catch (err) {
|
||||
return chip(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
const blocks = splitBlocksWithRanges(currentText);
|
||||
if (blocks.length === 0) return safe(currentText);
|
||||
return blocks.map((b) => `<div${srcAttr(b)}>${safe(b.raw)}</div>`).join("\n");
|
||||
}
|
||||
|
||||
export interface ProposalView {
|
||||
@@ -411,11 +726,13 @@ function renderReviewOp(
|
||||
op: BlockOp,
|
||||
render: (src: string) => string,
|
||||
colored: (raw: string) => string,
|
||||
src: 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).
|
||||
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render);
|
||||
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}">${colored(op.block.raw)}</div>`;
|
||||
// `src` is "" for a removed block (no live source — INV-36).
|
||||
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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,6 +757,14 @@ export function renderReview(
|
||||
const render = opts.render ?? defaultRender;
|
||||
const ranges = splitBlocksWithRanges(currentText);
|
||||
const ops = diffBlocks(baselineText, currentText);
|
||||
// #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.
|
||||
const clean = opts.pinned === true && ops.every((o) => o.kind === "unchanged");
|
||||
|
||||
// Associate each resolved proposal with the current-side block index whose range
|
||||
// it anchors into: the largest block with start <= anchorStart (the containing
|
||||
@@ -470,8 +795,8 @@ export function renderReview(
|
||||
const blockIndex = op.kind === "removed" ? -1 : ci;
|
||||
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
||||
const colored = (raw: string): string =>
|
||||
blk ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
|
||||
bodyParts.push(renderReviewOp(op, render, colored));
|
||||
blk && !clean ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
|
||||
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
|
||||
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
||||
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
||||
}
|
||||
|
||||
+248
-20
@@ -13,11 +13,38 @@ import * as vscode from "vscode";
|
||||
import type { DiffViewController } from "./diffViewController";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import type { ProposalController } from "./proposalController";
|
||||
import { renderReview, renderPlain, diffBlocks, type BlockOp } from "./trackChangesModel";
|
||||
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>();
|
||||
@@ -27,17 +54,58 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
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(
|
||||
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
|
||||
this.show(vscode.window.activeTextEditor?.document),
|
||||
),
|
||||
// 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 }) => {
|
||||
@@ -91,21 +159,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
// F10: the webview posts the annotations toggle + ✓/✗ proposal decisions back.
|
||||
// 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: { type?: string; mode?: "on" | "off"; proposalId?: string }) => {
|
||||
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);
|
||||
}
|
||||
},
|
||||
(m: ToolbarMsg) => this.handleWebviewMessage(document, m),
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
@@ -115,6 +172,148 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
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;
|
||||
@@ -145,8 +344,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
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) });
|
||||
void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current), authorable });
|
||||
return;
|
||||
}
|
||||
const spans = this.attribution.spansFor(document);
|
||||
@@ -159,9 +360,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
void panel.webview.postMessage({
|
||||
type: "render",
|
||||
mode,
|
||||
html: renderReview(baselineText, current, spans, proposals),
|
||||
html: renderReview(baselineText, current, spans, proposals, { pinned: baseline?.reason === "pinned" }),
|
||||
epoch: this.epochLabel(baseline),
|
||||
summary,
|
||||
authorable,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -232,6 +434,9 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
<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>
|
||||
@@ -246,6 +451,28 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
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);
|
||||
}
|
||||
@@ -260,6 +487,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
current,
|
||||
this.attribution.spansFor(doc),
|
||||
this.proposals.listProposals(doc),
|
||||
{ pinned: baseline?.reason === "pinned" },
|
||||
);
|
||||
}
|
||||
/** F10: current annotations mode for a panel (default on). */
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* turnProgress.ts — pure reduction of @cline/sdk Agent runtime events into a
|
||||
* small UI-facing progress snapshot (#60, spec coauthoring-live-progress.md §3.2).
|
||||
*
|
||||
* INV-43: vscode-free. INV-46: a pure function — no vscode, no SDK runtime
|
||||
* dependency (`AgentRuntimeEvent` is imported TYPE-only, so it is erased at
|
||||
* compile and never pulls the ESM SDK into the bundle). All event→state logic
|
||||
* lives here so it is unit-tested in isolation; the UI call sites only format and
|
||||
* relay snapshots.
|
||||
*/
|
||||
import type { AgentRuntimeEvent } from "@cline/shared";
|
||||
|
||||
export type TurnPhase = "thinking" | "writing" | "tool";
|
||||
|
||||
export interface TurnProgressSnapshot {
|
||||
phase: TurnPhase;
|
||||
/** present iff phase === "tool" — the running tool's name. */
|
||||
tool?: string;
|
||||
/** accumulated assistant-text length so far. */
|
||||
chars: number;
|
||||
/** running total tokens (input+output); undefined until the first usage event. */
|
||||
tokens?: number;
|
||||
/** the new assistant-text chunk since the last snapshot (for the OutputChannel). */
|
||||
textDelta?: string;
|
||||
}
|
||||
|
||||
export interface TurnProgressState {
|
||||
phase: TurnPhase;
|
||||
chars: number;
|
||||
tokens?: number;
|
||||
/** stack of tool names currently running (depth-tracked for overlap). */
|
||||
activeTools: string[];
|
||||
/** true once any assistant text has streamed (tool-finish then reverts to writing). */
|
||||
sawText: boolean;
|
||||
}
|
||||
|
||||
export function createTurnProgressState(): TurnProgressState {
|
||||
return { phase: "thinking", chars: 0, tokens: undefined, activeTools: [], sawText: false };
|
||||
}
|
||||
|
||||
function restingPhase(state: TurnProgressState): TurnPhase {
|
||||
if (state.activeTools.length) return "tool";
|
||||
return state.sawText ? "writing" : "thinking";
|
||||
}
|
||||
|
||||
function toSnapshot(state: TurnProgressState, textDelta?: string): TurnProgressSnapshot {
|
||||
return {
|
||||
phase: state.phase,
|
||||
tool: state.phase === "tool" ? state.activeTools[state.activeTools.length - 1] : undefined,
|
||||
chars: state.chars,
|
||||
tokens: state.tokens,
|
||||
textDelta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one SDK event into the state, returning the next state and the snapshot to
|
||||
* emit (snapshot undefined for events that don't change the surface).
|
||||
*/
|
||||
export function reduceTurnProgress(
|
||||
state: TurnProgressState,
|
||||
event: AgentRuntimeEvent,
|
||||
): { state: TurnProgressState; snapshot?: TurnProgressSnapshot } {
|
||||
switch (event.type) {
|
||||
case "run-started":
|
||||
case "turn-started": {
|
||||
const next: TurnProgressState = { ...state, phase: restingPhase(state) };
|
||||
return { state: next, snapshot: toSnapshot(next) };
|
||||
}
|
||||
case "assistant-text-delta": {
|
||||
const next: TurnProgressState = {
|
||||
...state,
|
||||
phase: state.activeTools.length ? "tool" : "writing",
|
||||
chars: event.accumulatedText.length,
|
||||
sawText: true,
|
||||
};
|
||||
return { state: next, snapshot: toSnapshot(next, event.text) };
|
||||
}
|
||||
case "assistant-reasoning-delta": {
|
||||
// Reasoning TEXT is not surfaced (operator fork); collapse to motion only.
|
||||
const next: TurnProgressState = { ...state, phase: state.activeTools.length ? "tool" : "thinking" };
|
||||
return { state: next, snapshot: toSnapshot(next) };
|
||||
}
|
||||
case "tool-started": {
|
||||
const activeTools = [...state.activeTools, event.toolCall.toolName];
|
||||
const next: TurnProgressState = { ...state, phase: "tool", activeTools };
|
||||
return { state: next, snapshot: toSnapshot(next) };
|
||||
}
|
||||
case "tool-updated": {
|
||||
const next: TurnProgressState = { ...state, phase: "tool" };
|
||||
return { state: next, snapshot: toSnapshot(next) };
|
||||
}
|
||||
case "tool-finished": {
|
||||
const name = event.toolCall.toolName;
|
||||
const idx = state.activeTools.lastIndexOf(name);
|
||||
const activeTools = idx >= 0 ? state.activeTools.filter((_, i) => i !== idx) : state.activeTools.slice(0, -1);
|
||||
const next: TurnProgressState = { ...state, activeTools, phase: "thinking" };
|
||||
next.phase = restingPhase(next);
|
||||
return { state: next, snapshot: toSnapshot(next) };
|
||||
}
|
||||
case "usage-updated": {
|
||||
const tokens = event.usage.inputTokens + event.usage.outputTokens || undefined;
|
||||
const next: TurnProgressState = { ...state, tokens };
|
||||
return { state: next, snapshot: toSnapshot(next) };
|
||||
}
|
||||
default:
|
||||
return { state };
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the notification activity line from a snapshot (pure; spec §2.1). */
|
||||
export function formatProgressLine(s: TurnProgressSnapshot): string {
|
||||
let head: string;
|
||||
if (s.phase === "tool") head = `running ${s.tool ?? "tool"}…`;
|
||||
else if (s.phase === "writing") head = `writing… (${s.chars} chars)`;
|
||||
else head = "thinking…";
|
||||
return s.tokens ? `${head} · ${formatTokens(s.tokens)} tokens` : head;
|
||||
}
|
||||
|
||||
export function formatTokens(n: number): string {
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||
}
|
||||
@@ -107,6 +107,25 @@ describe("multi-edit sequences", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyChange — geometric-only (undo/redo, #38)", () => {
|
||||
it("attributeInserted=false adds NO span for re-inserted text (restored text stays neutral)", () => {
|
||||
// Undo re-inserts 'bravo ' at offset 6 into an empty span list → no span.
|
||||
const r = applyChange([], { start: 6, end: 6, newLength: 6 }, HUMAN, ctx(), false);
|
||||
expect(r).toEqual([]);
|
||||
});
|
||||
it("attributeInserted=false still SHIFTS existing spans by the edit delta", () => {
|
||||
// A real human span sits after the re-insert point; it must shift right by 6,
|
||||
// but the re-inserted chars themselves get no new span.
|
||||
const r = applyChange([span("tail", 20, 30, HUMAN)], { start: 6, end: 6, newLength: 6 }, HUMAN, ctx(), false);
|
||||
expect(ranges(r)).toEqual([[26, 36]]);
|
||||
expect(authors(r)).toEqual(["human"]);
|
||||
});
|
||||
it("attributeInserted=false still reconciles geometry of a deletion (removes a covered span)", () => {
|
||||
const r = applyChange([span("a", 3, 6, AGENT)], { start: 0, end: 10, newLength: 0 }, HUMAN, ctx(), false);
|
||||
expect(r).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coalesce", () => {
|
||||
it("merges adjacent same-author same-turn spans, keeps earliest createdAt", () => {
|
||||
const a = { ...span("a", 0, 3, HUMAN), createdAt: "2026-06-09T00:00:00.000Z" };
|
||||
|
||||
@@ -40,8 +40,11 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
|
||||
"cowriting.toggleAttribution",
|
||||
"cowriting.acceptProposal",
|
||||
"cowriting.rejectProposal",
|
||||
// #34: the F6 two-pane diff VIEW was removed (F10 preview is the single
|
||||
// review surface); its toggle command is gone (the baseline store stays).
|
||||
"cowriting.toggleDiffView",
|
||||
]) {
|
||||
assert.ok(!all.includes(retired), `${retired} is retired (F10 preview-only)`);
|
||||
assert.ok(!all.includes(retired), `${retired} is retired`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,25 +76,24 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
|
||||
assert.strictEqual(api.sidecarRouter.sidecarPath(key), undefined, "untitled artifact is in-memory only (no disk)");
|
||||
});
|
||||
|
||||
// F6 (#19) is workspace-INDEPENDENT: its commands are real (not stubs) even
|
||||
// with no folder open, and the toggle works on an untitled buffer.
|
||||
test("F6 toggle works with no folder open (untitled buffer)", async () => {
|
||||
// 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.
|
||||
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.toggleDiffView"), "toggleDiffView registered");
|
||||
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline 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 new Promise((r) => setTimeout(r, 300));
|
||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||
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");
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const opened = vscode.window.tabGroups.all.some((g) =>
|
||||
g.tabs.some(
|
||||
(t) =>
|
||||
t.input instanceof vscode.TabInputTextDiff &&
|
||||
t.input.original.scheme === "cowriting-baseline" &&
|
||||
t.input.modified.toString() === untitled.uri.toString(),
|
||||
),
|
||||
);
|
||||
assert.ok(opened, "a cowriting-baseline diff opened for the untitled buffer with no folder");
|
||||
assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "pin works with no folder");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,10 +23,13 @@ async function getApi(): Promise<CowritingApi> {
|
||||
}
|
||||
const settle = () => new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
// Order-dependent (F2–F4 pattern): later tests consume earlier state. Owns
|
||||
// docs/diffview.md exclusively. F6 works on ANY file (#19), so the last two
|
||||
// tests use an out-of-workspace file and an untitled buffer.
|
||||
suite("F6 diff-view toggle (host E2E — any file, programmatic seam ingress, no LLM)", () => {
|
||||
// The F6 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. This suite covers that data layer. Order-dependent (F2–F4 pattern):
|
||||
// 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.
|
||||
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.";
|
||||
|
||||
@@ -40,21 +43,6 @@ suite("F6 diff-view toggle (host E2E — any file, programmatic seam ingress, no
|
||||
assert.strictEqual(baseline!.text, doc.getText(), "baseline = open-time buffer");
|
||||
});
|
||||
|
||||
test("toggle opens a diff tab (original scheme cowriting-baseline) over the live doc (PUC-1)", async () => {
|
||||
const api = await getApi();
|
||||
assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), false, "no diff open yet");
|
||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||
await settle();
|
||||
assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), true, "diff tab open");
|
||||
const active = vscode.window.tabGroups.activeTabGroup.activeTab;
|
||||
assert.ok(active && active.input instanceof vscode.TabInputTextDiff, "active tab is a diff");
|
||||
assert.strictEqual(
|
||||
(active!.input as vscode.TabInputTextDiff).original.scheme,
|
||||
"cowriting-baseline",
|
||||
"left side served by the baseline provider",
|
||||
);
|
||||
});
|
||||
|
||||
test("typing leaves the baseline unchanged while the buffer diverges", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
@@ -105,17 +93,17 @@ suite("F6 diff-view toggle (host E2E — any file, programmatic seam ingress, no
|
||||
);
|
||||
});
|
||||
|
||||
test("pin resets the baseline to now: baseline == buffer, reason pinned (PUC-3)", async () => {
|
||||
test("pin resets the baseline to now: baseline == buffer, reason pinned", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
|
||||
await settle();
|
||||
const baseline = api.diffViewController.getBaseline(docUri())!;
|
||||
assert.strictEqual(baseline.reason, "pinned");
|
||||
assert.strictEqual(baseline.text, doc.getText(), "pinned baseline == current buffer (diff empties)");
|
||||
assert.strictEqual(baseline.text, doc.getText(), "pinned baseline == current buffer (change-marks empty)");
|
||||
});
|
||||
|
||||
test("the baseline is persisted in GLOBAL storage, never the repo (PUC-4, INV-19)", async () => {
|
||||
test("the baseline is persisted in GLOBAL storage, never the repo (INV-19)", async () => {
|
||||
await openDoc();
|
||||
const api = await getApi();
|
||||
const p = api.diffViewController.baselineFilePath(docUri());
|
||||
@@ -131,25 +119,7 @@ suite("F6 diff-view toggle (host E2E — any file, programmatic seam ingress, no
|
||||
assert.ok(p!.includes(`${path.sep}baselines${path.sep}`), "baseline lives under <globalStorage>/baselines/");
|
||||
});
|
||||
|
||||
test("toggle again closes the diff tab and reveals the normal editor (PUC-1)", async () => {
|
||||
const api = await getApi();
|
||||
if (!api.diffViewController.isDiffOpen(docUri())) {
|
||||
await openDoc();
|
||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||
await settle();
|
||||
}
|
||||
assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), true, "diff open before close");
|
||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||
await settle();
|
||||
assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), false, "diff tab closed");
|
||||
assert.strictEqual(
|
||||
vscode.window.activeTextEditor?.document.uri.toString(),
|
||||
docUri(),
|
||||
"the normal editor for the doc is active after closing the diff",
|
||||
);
|
||||
});
|
||||
|
||||
test("toggle works on a file OUTSIDE the workspace folder, persisted in global storage (#19)", async () => {
|
||||
test("a baseline is captured + persisted for a file OUTSIDE the workspace folder (#19)", async () => {
|
||||
const api = await getApi();
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-outside-"));
|
||||
const outsidePath = path.join(outsideDir, "outside.md");
|
||||
@@ -162,19 +132,14 @@ suite("F6 diff-view toggle (host E2E — any file, programmatic seam ingress, no
|
||||
const baseline = api.diffViewController.getBaseline(outsideUri.toString());
|
||||
assert.ok(baseline, "baseline captured for an out-of-folder file");
|
||||
assert.strictEqual(baseline!.reason, "opened");
|
||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||
await settle();
|
||||
assert.strictEqual(api.diffViewController.isDiffOpen(outsideUri.toString()), true, "diff opens for the outside file");
|
||||
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");
|
||||
// close it so it doesn't bleed into the untitled test's active-tab checks
|
||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||
await settle();
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
void doc;
|
||||
});
|
||||
|
||||
test("toggle works on an UNTITLED buffer, baseline in-memory only (#19)", async () => {
|
||||
test("an UNTITLED buffer gets an in-memory baseline, never persisted to disk (#19)", async () => {
|
||||
const api = await getApi();
|
||||
const untitled = await vscode.workspace.openTextDocument({ content: "scratch line\n", language: "markdown" });
|
||||
await vscode.window.showTextDocument(untitled);
|
||||
@@ -184,8 +149,5 @@ suite("F6 diff-view toggle (host E2E — any file, programmatic seam ingress, no
|
||||
const baseline = api.diffViewController.getBaseline(key);
|
||||
assert.ok(baseline, "untitled buffer got an in-memory baseline");
|
||||
assert.strictEqual(api.diffViewController.baselineFilePath(key), undefined, "untitled is NOT persisted to disk");
|
||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||
await settle();
|
||||
assert.strictEqual(api.diffViewController.isDiffOpen(key), true, "diff opens for the untitled buffer");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,16 +215,19 @@ suite("F10 interactive review (host E2E — preview is the single review surface
|
||||
api.attributionController.getSpans("docs/f10clean.md").some((s) => s.authorKind === "agent"),
|
||||
"agent span recorded (attribution data layer intact)",
|
||||
);
|
||||
// the retired toggle is gone from the palette (no editor decorations — INV-32).
|
||||
// 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");
|
||||
// the F6 diff toggle's keybinding is hidden (when:false) — it is not a user surface.
|
||||
// #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" && k.key === "ctrl+alt+d",
|
||||
(k) => k.command === "cowriting.toggleDiffView",
|
||||
);
|
||||
assert.ok(dKb, "the ctrl+alt+d toggleDiffView keybinding is declared");
|
||||
assert.strictEqual(dKb!.when, "false", "…but hidden (when:false) — F6 is a data layer, not a user surface");
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
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";
|
||||
|
||||
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?.diffViewController, "exports preview + diffView");
|
||||
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() };
|
||||
}
|
||||
|
||||
// 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");
|
||||
});
|
||||
|
||||
// SLICE-1 reachability: the orphaned pin command gets a real palette `when`.
|
||||
test("pinDiffBaseline 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",
|
||||
);
|
||||
assert.ok(entry, "pinDiffBaseline 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");
|
||||
});
|
||||
|
||||
// #47 (was INV-37): Edit Document now cuts at BLOCK granularity — two word
|
||||
// changes in ONE paragraph are ONE block proposal (INV-39 supersedes INV-37's
|
||||
// per-word cut). Full block coverage lives in f12Review.test.ts.
|
||||
test("runEditAndPropose(document) — two word edits in one paragraph → ONE block proposal (PUC-4, INV-39)", async () => {
|
||||
const { doc, key } = await freshDoc(
|
||||
"docs/f11doc.md",
|
||||
"# 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();
|
||||
|
||||
// Stub the host edit turn (no LLM in CI): rewrite two distinct words.
|
||||
ctl.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");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "two changed words in one block → ONE block proposal (INV-39)");
|
||||
|
||||
const views = api.proposalController.listProposals(doc);
|
||||
const view = views.find((v) => v.id === ids[0]);
|
||||
assert.ok(view, "the returned proposal id is a live pending proposal");
|
||||
assert.strictEqual(
|
||||
view!.replacement,
|
||||
"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");
|
||||
void key;
|
||||
});
|
||||
|
||||
// SLICE-4: Edit Selection → one single-range proposal over the resolved block-union.
|
||||
test("runEditAndPropose(range) with a stubbed turn → exactly one proposal over the resolved range (PUC-3, INV-37)", async () => {
|
||||
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 target = "The target paragraph Claude will rewrite.";
|
||||
const start = doc.getText().indexOf(target);
|
||||
const end = start + target.length;
|
||||
ctl.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");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal");
|
||||
const views = api.proposalController.listProposals(doc);
|
||||
const view = views.find((v) => v.id === ids[0]);
|
||||
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)");
|
||||
void key;
|
||||
});
|
||||
|
||||
// SLICE-4: a no-op turn (Claude returns the input unchanged) produces no proposal.
|
||||
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 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");
|
||||
assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing");
|
||||
});
|
||||
|
||||
// SLICE-3 (review follow-up): a document rewrite that INSERTS text → an
|
||||
// acceptable proposal (not a born-orphaned zero-width hunk), and accepting all
|
||||
// hunks of a multi-hunk rewrite lands the intended document.
|
||||
test("document rewrite with an insertion → acceptable proposals; accept-all reaches the rewrite", async () => {
|
||||
const original = "# F11 accept\n\nThe brown fox sleeps.\n";
|
||||
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();
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "expand the sentence");
|
||||
await settle();
|
||||
assert.ok(ids.length >= 1, "the rewrite produced at least one proposal");
|
||||
|
||||
// Every proposal must be acceptable (the insertion-anchoring fix): accept each.
|
||||
for (const id of ids) {
|
||||
const ok = await api.proposalController.acceptById("docs/f11accept.md", id);
|
||||
assert.ok(ok, `proposal ${id} is acceptable (not born-orphaned)`);
|
||||
await settle();
|
||||
}
|
||||
assert.strictEqual(doc.getText(), rewrite, "accepting all hunks reconstructs the intended rewrite");
|
||||
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 () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
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";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers");
|
||||
return api;
|
||||
}
|
||||
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
// #46 SLICE-3 (accept): one "Accept all" gesture applies every pending proposal
|
||||
// on the current document through the existing F4 accept seam (INV-42) — batched,
|
||||
// re-anchor-safe (descending), orphan-skip + report. Host E2E, no LLM.
|
||||
suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
|
||||
// PUC-6: N pending → all applied, text replaced, proposals cleared.
|
||||
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 api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" }));
|
||||
const ids = await ctl.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" });
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
assert.strictEqual(doc.getText(), rewrite, "accept-all reconstructs the intended document");
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "all proposals cleared");
|
||||
});
|
||||
|
||||
// PUC-6 / INV-42: an orphaned proposal is skipped (not force-applied) and the
|
||||
// resolvable ones still apply — reported via the {applied, skipped} tally.
|
||||
test("acceptAllProposals skips an orphaned proposal and applies the rest (report)", async () => {
|
||||
const original = "# Mix\n\nKeep alpha here.\n\nKeep gamma here.\n";
|
||||
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();
|
||||
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 2, "two pending proposals");
|
||||
|
||||
// Orphan the FIRST block's proposal by mangling its target text in the buffer.
|
||||
const para = "Keep alpha here.";
|
||||
const start = doc.getText().indexOf(para);
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.replace(
|
||||
doc.uri,
|
||||
new vscode.Range(doc.positionAt(start), doc.positionAt(start + para.length)),
|
||||
"Totally different first paragraph now.",
|
||||
);
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "mangling edit applied");
|
||||
await settle();
|
||||
|
||||
const { applied, skipped } = await api.proposalController.acceptAllProposals(doc);
|
||||
assert.strictEqual(applied, 1, "the still-resolvable proposal applied");
|
||||
assert.strictEqual(skipped, 1, "the orphaned proposal was skipped, not mangled");
|
||||
assert.ok(doc.getText().includes("Keep GAMMA here."), "the resolvable block landed");
|
||||
assert.ok(
|
||||
doc.getText().includes("Totally different first paragraph now."),
|
||||
"the orphaned block kept the operator's text (never force-applied)",
|
||||
);
|
||||
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).
|
||||
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 () => ({
|
||||
replacement: "# One\n\nThe ONLY paragraph here.\n",
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-f12-one",
|
||||
}));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase only");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "one pending proposal");
|
||||
const { applied, skipped } = await api.proposalController.acceptAllProposals(doc);
|
||||
assert.strictEqual(applied, 1);
|
||||
assert.strictEqual(skipped, 0);
|
||||
assert.strictEqual(doc.getText(), "# One\n\nThe ONLY paragraph here.\n");
|
||||
});
|
||||
|
||||
// The command is registered + palette-guarded on markdown.
|
||||
test("cowriting.acceptAllProposals is registered and palette-guarded on markdown", async () => {
|
||||
const all = await vscode.commands.getCommands(true);
|
||||
assert.ok(all.includes("cowriting.acceptAllProposals"), "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.acceptAllProposals",
|
||||
);
|
||||
assert.ok(entry, "has a commandPalette entry");
|
||||
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
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";
|
||||
|
||||
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, "exports preview controller");
|
||||
return api;
|
||||
}
|
||||
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
function pkg(): any {
|
||||
return JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||
}
|
||||
function menu(id: string): Array<{ command: string; when?: string; group?: string }> {
|
||||
return pkg().contributes.menus[id] ?? [];
|
||||
}
|
||||
|
||||
// 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.
|
||||
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", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
// 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", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
// 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).
|
||||
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");
|
||||
const b = await freshDoc("docs/f12-tab.md", "# Tab\n\nThe tab target paragraph to rewrite.\n");
|
||||
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",
|
||||
}));
|
||||
try {
|
||||
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
|
||||
await settle();
|
||||
} finally {
|
||||
(vscode.window as any).showInputBox = origInput;
|
||||
}
|
||||
|
||||
// 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",
|
||||
);
|
||||
// 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",
|
||||
}));
|
||||
try {
|
||||
await vscode.commands.executeCommand("cowriting.editDocument");
|
||||
await settle();
|
||||
} finally {
|
||||
(vscode.window as any).showInputBox = origInput;
|
||||
}
|
||||
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
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";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers");
|
||||
return api;
|
||||
}
|
||||
|
||||
async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
return doc;
|
||||
}
|
||||
|
||||
// #47 SLICE-2 (review, P1): a document rewrite proposes ONE F4 proposal per
|
||||
// CHANGED BLOCK (INV-39 supersedes INV-37's per-word cut), but accepting a block
|
||||
// reconciles attribution at WORD granularity (INV-40 — block = decision unit,
|
||||
// word = attribution unit). Host E2E, no LLM (the edit turn is stubbed).
|
||||
suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)", () => {
|
||||
// PUC-4: M changed blocks → M proposals; unchanged blocks → none.
|
||||
test("edits across two paragraphs → two proposals; the untouched paragraph yields none (INV-39)", async () => {
|
||||
const doc = await freshDoc(
|
||||
"docs/f12-multi.md",
|
||||
"# 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 () => ({
|
||||
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");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 2, "two changed blocks → two proposals; the unchanged middle block → none");
|
||||
|
||||
// each proposal's anchor spans a whole block, and the replacement is that block's rewrite
|
||||
const views = api.proposalController.listProposals(doc);
|
||||
const replacements = views.map((v) => v.replacement).sort();
|
||||
assert.deepStrictEqual(replacements, ["First paragraph ALPHA.", "Third paragraph GAMMA."]);
|
||||
});
|
||||
|
||||
// PUC-4 + INV-23: a changed code fence is ONE atomic whole-fence proposal.
|
||||
test("a changed code fence → one atomic proposal over the whole fence (INV-23)", async () => {
|
||||
const doc = await freshDoc(
|
||||
"docs/f12-fence.md",
|
||||
"# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n",
|
||||
);
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
ctl.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");
|
||||
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])!;
|
||||
assert.strictEqual(view.replacement, "```js\nconst a = 10;\nconst b = 2;\n```", "whole-fence replacement");
|
||||
});
|
||||
|
||||
// PUC-5 / INV-40: accepting a block attributes ONLY the words Claude changed —
|
||||
// unchanged words in the block are NOT swept into Claude's authorship.
|
||||
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 key = api.proposalController.keyFor(doc);
|
||||
|
||||
ctl.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");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "one changed paragraph → one block proposal");
|
||||
|
||||
const ok = await api.proposalController.acceptById(key, ids[0]);
|
||||
assert.ok(ok, "the block proposal accepts");
|
||||
await settle();
|
||||
|
||||
assert.strictEqual(doc.getText(), "# T\n\nThe quick RED fox jumps SLOWLY.\n", "the whole block landed");
|
||||
|
||||
const agentTexts = api.attributionController
|
||||
.getSpans(key)
|
||||
.filter((s) => s.authorKind === "agent")
|
||||
.map((s) => doc.getText().slice(s.range.start, s.range.end).trim())
|
||||
.filter((t) => t.length > 0);
|
||||
// Only the two words Claude actually changed are Claude-attributed.
|
||||
const joined = agentTexts.join(" ");
|
||||
assert.ok(joined.includes("RED"), "the changed word RED is Claude-attributed");
|
||||
assert.ok(joined.includes("SLOWLY"), "the changed word SLOWLY is Claude-attributed");
|
||||
assert.ok(!/\bquick\b/.test(joined), "the unchanged word 'quick' is NOT swept into Claude's authorship");
|
||||
assert.ok(!/\bfox\b/.test(joined), "the unchanged word 'fox' is NOT swept into Claude's authorship");
|
||||
});
|
||||
|
||||
// PUC-4 / INV-41: a block-insertion rewrite produces acceptable proposals and
|
||||
// accepting them all reconstructs the intended document (no born-orphaned hunk).
|
||||
test("a rewrite that inserts a paragraph → acceptable proposals; accept-all reaches the rewrite (INV-41)", async () => {
|
||||
const original = "# Ins\n\nAlpha block.\n\nBeta block.\n";
|
||||
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 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");
|
||||
await settle();
|
||||
assert.ok(ids.length >= 1, "the insertion produced at least one proposal");
|
||||
|
||||
for (const id of ids) {
|
||||
const ok = await api.proposalController.acceptById(key, id);
|
||||
assert.ok(ok, `proposal ${id} is acceptable (not born-orphaned, INV-41)`);
|
||||
await settle();
|
||||
}
|
||||
assert.strictEqual(doc.getText(), rewrite, "accepting all proposals reconstructs the intended rewrite");
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
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";
|
||||
|
||||
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, "exports preview controller");
|
||||
return api;
|
||||
}
|
||||
|
||||
async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
return doc;
|
||||
}
|
||||
|
||||
// #60 host E2E (no LLM): live progress is purely additive observability. The
|
||||
// harness can't read notification subtitles, so it asserts the CONTRACT —
|
||||
// progress events don't change the proposals (INV-44), and an aborted turn
|
||||
// proposes nothing (INV-47). The live notification/OutputChannel UI itself is
|
||||
// covered by the turnProgress unit tests + the manual smoke.
|
||||
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();
|
||||
|
||||
// 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) => {
|
||||
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");
|
||||
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)");
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
// 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) => {
|
||||
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" };
|
||||
});
|
||||
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
let ids: string[] = [];
|
||||
try {
|
||||
ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
|
||||
} catch {
|
||||
ids = [];
|
||||
}
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 0, "aborted turn must create no proposals");
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no pending proposals after abort");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
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";
|
||||
|
||||
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, "exports preview controller");
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Write a markdown file under WS and return its uri (without opening it). */
|
||||
function writeFile(rel: string, body: string): vscode.Uri {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
return vscode.Uri.file(abs);
|
||||
}
|
||||
|
||||
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 () => {
|
||||
// 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");
|
||||
const aDoc = await vscode.workspace.openTextDocument(aUri);
|
||||
await vscode.window.showTextDocument(aDoc);
|
||||
const bDoc = await vscode.workspace.openTextDocument(bUri); // open but NOT active
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview", 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",
|
||||
);
|
||||
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 () => {
|
||||
// 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();
|
||||
const api = await getApi();
|
||||
assert.ok(
|
||||
!vscode.workspace.textDocuments.some((d) => d.uri.toString() === key),
|
||||
"precondition: the file is not an open document",
|
||||
);
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview", uri);
|
||||
await settle();
|
||||
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "the clicked-but-unopened file got a preview");
|
||||
});
|
||||
|
||||
test("no-arg invocation still falls back to the active editor (palette / keybinding unchanged)", async () => {
|
||||
const uri = writeFile("docs/menu-noarg.md", "# No-arg\n\nActive editor for the no-arg path.\n");
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
const api = await getApi();
|
||||
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(uri.toString()), true, "no-arg previews the active editor");
|
||||
});
|
||||
|
||||
test("explorer/context contributes the review-panel 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");
|
||||
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", () => {
|
||||
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");
|
||||
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("the ctrl+alt+r keybinding still targets the command (unchanged)", () => {
|
||||
const kb = (pkg().contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find(
|
||||
(k) => k.command === "cowriting.showTrackChangesPreview",
|
||||
);
|
||||
assert.ok(kb, "keybinding still present");
|
||||
assert.strictEqual(kb!.key, "ctrl+alt+r", "key unchanged");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
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";
|
||||
|
||||
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, "exports preview controller");
|
||||
return api;
|
||||
}
|
||||
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
// #48 host E2E (no LLM): pinning the baseline leaves the review panel fully clean
|
||||
// — no authorship coloring on unchanged blocks — while re-divergence brings the
|
||||
// annotations back. The author colors are read from the on-state renderReview HTML.
|
||||
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");
|
||||
|
||||
// Pin the baseline → zero diff → the panel must be fully clean.
|
||||
ctl.receiveMessage(key, { type: "pinBaseline" });
|
||||
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");
|
||||
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");
|
||||
|
||||
// Edit again → there are changes since the pinned baseline → annotations return.
|
||||
const edit2 = new vscode.WorkspaceEdit();
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
|
||||
/**
|
||||
* #54: `vscode.commands.executeCommand("undo")` is non-functional in some headless
|
||||
* `.vscode-test` instances (it does not restore the buffer), which false-fails
|
||||
* every undo-dependent E2E (#38, #40) and makes `main` E2E red there. This is a
|
||||
* RUNTIME probe: it performs a real edit-then-undo on a scratch buffer and reports
|
||||
* whether undo actually restored it. Undo-dependent suites gate on it — running
|
||||
* normally where undo works (real coverage), skipping with a loud warning where it
|
||||
* doesn't (no false red, no silent loss — the skip is logged). Memoized per run.
|
||||
*/
|
||||
let cached: boolean | undefined;
|
||||
|
||||
export async function undoWorks(): Promise<boolean> {
|
||||
if (cached !== undefined) return cached;
|
||||
const abs = path.join(process.env.E2E_WORKSPACE!, "docs/.undo-probe.md");
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, "undo probe baseline\n", "utf8");
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "PROBE-EDIT-MARKER");
|
||||
await vscode.workspace.applyEdit(edit);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
if (!doc.getText().includes("PROBE-EDIT-MARKER")) {
|
||||
cached = false; // even the edit didn't take — treat as not undo-capable
|
||||
return cached;
|
||||
}
|
||||
await vscode.commands.executeCommand("undo");
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
cached = !doc.getText().includes("PROBE-EDIT-MARKER"); // undo removed the marker → undo works
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Loud, single-line reason logged when an undo suite skips (no silent loss — #54). */
|
||||
export const UNDO_SKIP_REASON =
|
||||
"[E2E] SKIPPING undo-dependent suite — executeCommand('undo') is non-functional in this " +
|
||||
"VS Code test instance (see vscode-cowriting-plugin#54). These tests run where undo works.";
|
||||
@@ -0,0 +1,85 @@
|
||||
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 { undoWorks, UNDO_SKIP_REASON } from "./undoCapable";
|
||||
|
||||
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?.attributionController && api?.trackChangesPreviewController, "exports attribution + preview");
|
||||
return api;
|
||||
}
|
||||
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
// #38 (P1): undo in the editor renders WRONG marks in the F10 review preview.
|
||||
// Root cause: attribution attributes every non-seam change to the human and
|
||||
// ignores e.reason, so an undo that re-inserts text falsely colors it human.
|
||||
// We drive a MID-EDIT undo (buffer stays dirty, so the disk-sync guard doesn't
|
||||
// mask it) and assert the restored baseline text is NOT re-attributed.
|
||||
suite("F10 #38 — undo does not mis-attribute restored text (host E2E, no LLM)", () => {
|
||||
const DOC_REL = "docs/undo38.md";
|
||||
const BASE = "Alpha bravo charlie.\n";
|
||||
|
||||
// #54: skip (loudly) where executeCommand("undo") is non-functional; run where it works.
|
||||
suiteSetup(async function () {
|
||||
if (!(await undoWorks())) {
|
||||
console.warn(UNDO_SKIP_REASON);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test("undo of a deletion of baseline text leaves it unattributed (not human)", async () => {
|
||||
const { doc, key } = await freshDoc(DOC_REL, BASE);
|
||||
const api = await getApi();
|
||||
|
||||
// Forward edit 1 (human): append a tail so a LATER undo of edit 2 keeps the
|
||||
// buffer dirty (≠ disk) → the attribution branch runs, not the disk-sync one.
|
||||
const e1 = new vscode.WorkspaceEdit();
|
||||
e1.insert(doc.uri, doc.positionAt(doc.getText().length), "\nHuman tail.\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(e1), "edit 1 applied");
|
||||
await settle();
|
||||
|
||||
// Forward edit 2 (human): delete the baseline word "bravo " (offsets 6..12).
|
||||
const e2 = new vscode.WorkspaceEdit();
|
||||
e2.delete(doc.uri, new vscode.Range(doc.positionAt(6), doc.positionAt(12)));
|
||||
assert.ok(await vscode.workspace.applyEdit(e2), "edit 2 applied");
|
||||
await settle();
|
||||
assert.ok(!doc.getText().includes("bravo"), "bravo deleted");
|
||||
|
||||
// Undo edit 2 → "bravo " is re-inserted. It is RESTORED baseline text, not
|
||||
// freshly authored — it must NOT become a human-attributed span.
|
||||
await vscode.commands.executeCommand("undo");
|
||||
await settle();
|
||||
assert.ok(doc.getText().includes("Alpha bravo charlie."), "undo restored 'bravo '");
|
||||
assert.ok(doc.isDirty, "buffer still dirty (mid-edit undo → attribution branch, not disk-sync)");
|
||||
|
||||
const bravoStart = doc.getText().indexOf("bravo");
|
||||
const spans = api.attributionController.spansFor(doc);
|
||||
const overBravo = spans.filter((s) => s.start < bravoStart + 5 && s.end > bravoStart);
|
||||
assert.deepStrictEqual(
|
||||
overBravo,
|
||||
[],
|
||||
`restored baseline text 'bravo' must be unattributed, got spans: ${JSON.stringify(overBravo)}`,
|
||||
);
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
+59
-2
@@ -1,5 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractReplacement } from "../src/liveTurn";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { extractReplacement, runEditTurn } from "../src/liveTurn";
|
||||
|
||||
// A fake Agent that replays a scripted event list to subscribers, supports abort,
|
||||
// and resolves agent.run() with a completed (or aborted) result.
|
||||
const runs = { count: 0 };
|
||||
function fakeSdk(events: any[]) {
|
||||
return {
|
||||
Agent: class {
|
||||
private listeners: ((e: any) => void)[] = [];
|
||||
constructor(_cfg: unknown) {}
|
||||
subscribe(fn: (e: any) => void) {
|
||||
this.listeners.push(fn);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter((l) => l !== fn);
|
||||
};
|
||||
}
|
||||
// The real SDK's abort() is a no-op before run() creates its AbortController,
|
||||
// so this fake does NOT cooperate with a pre-abort — proving runEditTurn's own
|
||||
// short-circuit, not the fake's leniency.
|
||||
abort() {}
|
||||
async run(_input: string) {
|
||||
runs.count += 1;
|
||||
for (const e of events) for (const l of this.listeners) l(e);
|
||||
return { status: "completed", outputText: "EDITED", runId: "r1" };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("extractReplacement", () => {
|
||||
it("returns plain text untouched", () => {
|
||||
@@ -24,3 +51,33 @@ describe("extractReplacement", () => {
|
||||
expect(extractReplacement("```\nplain\n```", "plain old text")).toBe("plain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runEditTurn progress + cancel", () => {
|
||||
it("emits progress snapshots and returns the replacement unchanged (INV-44)", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@cline/sdk", () =>
|
||||
fakeSdk([
|
||||
{ type: "assistant-text-delta", text: "ED", accumulatedText: "ED" },
|
||||
{ type: "usage-updated", usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
||||
]),
|
||||
);
|
||||
const seen: string[] = [];
|
||||
const turn = await runEditTurn("do it", "old", { onProgress: (s) => seen.push(s.phase) });
|
||||
expect(turn.replacement).toBe("EDITED");
|
||||
expect(seen).toContain("writing");
|
||||
vi.doUnmock("@cline/sdk");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("a pre-aborted AbortSignal short-circuits before run() and throws (INV-47)", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@cline/sdk", () => fakeSdk([]));
|
||||
runs.count = 0;
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(runEditTurn("do it", "old", { signal: ac.signal })).rejects.toThrow(/aborted/);
|
||||
expect(runs.count).toBe(0); // the turn never ran — no proposal could be produced
|
||||
vi.doUnmock("@cline/sdk");
|
||||
vi.resetModules();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, test, expect } from "vitest";
|
||||
import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
|
||||
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
|
||||
|
||||
describe("splitBlocks", () => {
|
||||
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
||||
@@ -168,6 +168,45 @@ describe("colorByAuthor", () => {
|
||||
expect(html).toContain('<span class="cw-by-human">hello</span>');
|
||||
expect(html).toContain("world");
|
||||
});
|
||||
|
||||
// #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.
|
||||
// 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" }], []);
|
||||
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)
|
||||
expect(html).toContain('class="cw-by-human"'); // coloring present
|
||||
});
|
||||
// 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"
|
||||
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" }], []);
|
||||
expect(html).toContain("<strong>");
|
||||
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
|
||||
expect(html).toContain("bold");
|
||||
});
|
||||
test("#33: no Private-Use-Area sentinel chars leak into the rendered output", () => {
|
||||
const doc = "a**b**c and `co de` and _x_";
|
||||
const spans: AuthorSpan[] = [
|
||||
{ start: 0, end: 2, author: "human" },
|
||||
{ start: 12, end: 16, author: "claude" },
|
||||
];
|
||||
const html = renderReview(doc, doc, spans, []);
|
||||
expect(html).not.toMatch(/[\uE000-\uF8FF]/); // no leftover BMP Private-Use-Area sentinels
|
||||
});
|
||||
});
|
||||
|
||||
import { renderPlain } from "../src/trackChangesModel";
|
||||
@@ -277,6 +316,54 @@ describe("renderReview", () => {
|
||||
expect(uIdx).toBeGreaterThan(html.indexOf("Beta there")); // unanchored still trails the body
|
||||
expect(html).toContain("cw-proposal-unanchored");
|
||||
});
|
||||
// #48: a PINNED baseline with zero changes leaves the panel fully un-annotated —
|
||||
// no authorship coloring on unchanged blocks — while pending proposals still show.
|
||||
test("renderReview: pinned + zero diff with author spans renders NO authorship coloring", () => {
|
||||
const doc = "Human wrote this.\n\nClaude wrote that.";
|
||||
const spans: AuthorSpan[] = [
|
||||
{ start: 0, end: 17, author: "human" },
|
||||
{ start: 19, end: doc.length, author: "claude" },
|
||||
];
|
||||
const html = renderReview(doc, doc, spans, [], { pinned: true });
|
||||
expect(html).not.toContain("cw-by-human");
|
||||
expect(html).not.toContain("cw-by-claude");
|
||||
expect(html).not.toContain("cw-add");
|
||||
expect(html).not.toContain("cw-del");
|
||||
// still a selection→source surface (INV-36): blocks carry data-src offsets.
|
||||
expect(html).toContain("data-src-start");
|
||||
// the body text is still there, just plain.
|
||||
expect(html).toContain("Human wrote this.");
|
||||
expect(html).toContain("Claude wrote that.");
|
||||
});
|
||||
test("renderReview: pinned + zero diff still renders a pending proposal block", () => {
|
||||
const doc = "Human wrote this.\n\nClaude wrote that.";
|
||||
const spans: AuthorSpan[] = [{ start: 0, end: 17, author: "human" }];
|
||||
const proposals: ProposalView[] = [
|
||||
{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "Human", replacement: "Person" },
|
||||
];
|
||||
const html = renderReview(doc, doc, spans, proposals, { pinned: true });
|
||||
expect(html).not.toContain("cw-by-human"); // body still clean
|
||||
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.
|
||||
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");
|
||||
});
|
||||
test("renderReview: with REAL changes since a pin, author coloring returns", () => {
|
||||
// a genuine added block is still author-colored even when pinned (only the
|
||||
// zero-diff-after-pin state is clean).
|
||||
const baseline = "Hello world";
|
||||
const current = "Hello world\n\nHello world";
|
||||
const spans: AuthorSpan[] = [{ start: 19, end: 24, author: "human" }];
|
||||
const html = renderReview(baseline, current, spans, [], { pinned: true });
|
||||
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
|
||||
});
|
||||
|
||||
test("renderReview is deterministic with mixed anchored/unanchored proposals", () => {
|
||||
const doc = "one two three";
|
||||
const proposals: ProposalView[] = [
|
||||
@@ -319,3 +406,236 @@ describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {
|
||||
expect(html).not.toContain("classDef cwAdded");
|
||||
});
|
||||
});
|
||||
|
||||
// F11 SLICE-3 (INV-37, §6.4): a whole-document rewrite is diffed into per-hunk
|
||||
// proposal ranges — each an independent F4 single-range proposal. Pure,
|
||||
// vscode-free, deterministic; offsets index into currentText.
|
||||
describe("F11 diffToHunks (INV-37)", () => {
|
||||
test("an identical rewrite → zero hunks", () => {
|
||||
expect(diffToHunks("The same text.\n", "The same text.\n")).toEqual([]);
|
||||
});
|
||||
|
||||
test("a single changed word → one hunk over exactly that word", () => {
|
||||
const current = "The quick brown fox.";
|
||||
const hunks = diffToHunks(current, "The quick red fox.");
|
||||
expect(hunks).toHaveLength(1);
|
||||
expect(current.slice(hunks[0].start, hunks[0].end)).toBe("brown");
|
||||
expect(hunks[0].replacement).toBe("red");
|
||||
});
|
||||
|
||||
test("two disjoint changes → two hunks with correct ranges + replacements", () => {
|
||||
const current = "one two three four";
|
||||
const hunks = diffToHunks(current, "one TWO three FOUR");
|
||||
expect(hunks).toHaveLength(2);
|
||||
expect(current.slice(hunks[0].start, hunks[0].end)).toBe("two");
|
||||
expect(hunks[0].replacement).toBe("TWO");
|
||||
expect(current.slice(hunks[1].start, hunks[1].end)).toBe("four");
|
||||
expect(hunks[1].replacement).toBe("FOUR");
|
||||
// disjoint + ordered
|
||||
expect(hunks[0].end).toBeLessThanOrEqual(hunks[1].start);
|
||||
});
|
||||
|
||||
test("a wholesale replacement (nothing in common) → one full-range hunk", () => {
|
||||
const current = "alpha";
|
||||
const hunks = diffToHunks(current, "omega");
|
||||
expect(hunks).toHaveLength(1);
|
||||
expect(hunks[0]).toEqual({ start: 0, end: current.length, replacement: "omega" });
|
||||
});
|
||||
|
||||
test("is deterministic — same inputs → identical hunks", () => {
|
||||
const a = diffToHunks("a b c d", "a B c D");
|
||||
const b = diffToHunks("a b c d", "a B c D");
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
/** Apply hunks (right→left so earlier offsets stay valid) to reconstruct the rewrite. */
|
||||
const applyHunks = (current: string, hunks: ReturnType<typeof diffToHunks>): string => {
|
||||
let out = current;
|
||||
for (const h of [...hunks].sort((a, b) => b.start - a.start)) {
|
||||
out = out.slice(0, h.start) + h.replacement + out.slice(h.end);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
test("applying the hunks always reconstructs the rewrite (substitute / delete / insert / multi)", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["The quick brown fox.", "The quick red fox."],
|
||||
["one two three four", "one TWO three FOUR"],
|
||||
["alpha", "omega"],
|
||||
["keep this and drop that", "keep this"],
|
||||
["one two three", "one INSERTED two three"],
|
||||
["start middle end", "PREFIX start middle end SUFFIX"],
|
||||
["unchanged body", "unchanged body"],
|
||||
];
|
||||
for (const [current, rewrite] of cases) {
|
||||
expect(applyHunks(current, diffToHunks(current, rewrite))).toBe(rewrite);
|
||||
}
|
||||
});
|
||||
|
||||
test("an inserted run anchors to adjacent text — never a zero-width, unacceptable hunk (INV-37)", () => {
|
||||
// A pure insertion would otherwise produce start==end → an empty fingerprint
|
||||
// → resolve() orphans it → the proposal can never be accepted. Each hunk must
|
||||
// span real source text so its F4 fingerprint resolves.
|
||||
for (const [current, rewrite] of [
|
||||
["one two three", "one INSERTED two three"],
|
||||
["tail anchor", "tail anchor APPENDED"],
|
||||
["lead", "PREPENDED lead"],
|
||||
] as Array<[string, string]>) {
|
||||
for (const h of diffToHunks(current, rewrite)) {
|
||||
expect(h.end).toBeGreaterThan(h.start); // non-zero-width
|
||||
expect(current.slice(h.start, h.end).length).toBeGreaterThan(0); // real fp.text
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// #47 SLICE-2 (INV-39): a whole-document rewrite is diffed into ONE EditHunk per
|
||||
// CHANGED BLOCK (the unit a human reviews), superseding INV-37's per-word hunks.
|
||||
// Built by coarsening diffToHunks to block boundaries; fences stay atomic
|
||||
// (INV-23); inserted/deleted blocks keep their anchored gap hunks (INV-41).
|
||||
// Applying all hunks right→left must still reconstruct the rewrite exactly.
|
||||
describe("#47 diffToBlockHunks (INV-39/41)", () => {
|
||||
const applyHunks = (current: string, hunks: ReturnType<typeof diffToBlockHunks>): string => {
|
||||
let out = current;
|
||||
for (const h of [...hunks].sort((a, b) => b.start - a.start)) {
|
||||
out = out.slice(0, h.start) + h.replacement + out.slice(h.end);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
test("an identical rewrite → zero hunks", () => {
|
||||
expect(diffToBlockHunks("# H\n\nSame body.\n", "# H\n\nSame body.\n")).toEqual([]);
|
||||
});
|
||||
|
||||
test("two word edits in ONE paragraph → ONE block hunk (supersedes INV-37's two)", () => {
|
||||
const current = "# Doc\n\nThe quick brown fox jumps over the lazy dog.\n";
|
||||
const rewrite = "# Doc\n\nThe quick RED fox jumps over the lazy CAT.\n";
|
||||
const hunks = diffToBlockHunks(current, rewrite);
|
||||
expect(hunks).toHaveLength(1);
|
||||
// the hunk spans the whole changed paragraph block
|
||||
const para = "The quick brown fox jumps over the lazy dog.";
|
||||
const start = current.indexOf(para);
|
||||
expect(hunks[0].start).toBe(start);
|
||||
expect(hunks[0].end).toBe(start + para.length);
|
||||
expect(hunks[0].replacement).toBe("The quick RED fox jumps over the lazy CAT.");
|
||||
expect(applyHunks(current, hunks)).toBe(rewrite);
|
||||
});
|
||||
|
||||
test("edits across two paragraphs → two block hunks, one per changed block; unchanged → none", () => {
|
||||
const current = "First para alpha.\n\nSecond para beta.\n\nThird para gamma.\n";
|
||||
const rewrite = "First para ALPHA.\n\nSecond para beta.\n\nThird para GAMMA.\n";
|
||||
const hunks = diffToBlockHunks(current, rewrite);
|
||||
expect(hunks).toHaveLength(2);
|
||||
// each hunk lands on a real block boundary in current
|
||||
const blocks = splitBlocksWithRanges(current);
|
||||
for (const h of hunks) {
|
||||
expect(blocks.some((b) => b.start === h.start && b.end === h.end)).toBe(true);
|
||||
}
|
||||
expect(applyHunks(current, hunks)).toBe(rewrite);
|
||||
});
|
||||
|
||||
test("a changed code fence → ONE atomic whole-fence hunk (INV-23)", () => {
|
||||
const current = "# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n";
|
||||
const rewrite = "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n";
|
||||
const hunks = diffToBlockHunks(current, rewrite);
|
||||
expect(hunks).toHaveLength(1);
|
||||
const fence = "```js\nconst a = 1;\nconst b = 2;\n```";
|
||||
const start = current.indexOf(fence);
|
||||
expect(hunks[0].start).toBe(start);
|
||||
expect(hunks[0].end).toBe(start + fence.length);
|
||||
expect(hunks[0].replacement).toBe("```js\nconst a = 10;\nconst b = 2;\n```");
|
||||
expect(applyHunks(current, hunks)).toBe(rewrite);
|
||||
});
|
||||
|
||||
test("every hunk is resolvable (non-zero-width, real source text) and reconstructs", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["# H\n\nThe brown fox sleeps.\n", "# H\n\nThe brown fox QUIETLY sleeps today.\n"], // insert words mid-block
|
||||
["A para.\n\nB para.\n\nC para.\n", "A para.\n\nC para.\n"], // delete a whole block
|
||||
["A para.\n\nB para.\n", "A para.\n\nNEW para.\n\nB para.\n"], // insert a whole block
|
||||
["Only one block here.\n", "A totally different single block.\n"], // wholesale
|
||||
["Keep me.\n\nDrop this one.\n", "Keep me.\n"], // delete trailing block
|
||||
["unchanged body\n", "unchanged body\n"], // no-op
|
||||
];
|
||||
for (const [current, rewrite] of cases) {
|
||||
const hunks = diffToBlockHunks(current, rewrite);
|
||||
for (const h of hunks) {
|
||||
expect(h.end).toBeGreaterThan(h.start); // non-zero-width → F4 fp resolves
|
||||
expect(current.slice(h.start, h.end).length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(applyHunks(current, hunks)).toBe(rewrite);
|
||||
}
|
||||
});
|
||||
|
||||
test("is deterministic — same inputs → identical hunks", () => {
|
||||
const c = "P one.\n\nP two.\n";
|
||||
const r = "P ONE.\n\nP two.\n";
|
||||
expect(diffToBlockHunks(c, r)).toEqual(diffToBlockHunks(c, r));
|
||||
});
|
||||
});
|
||||
|
||||
// F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end
|
||||
// (source char offsets from BlockWithRange) on every LIVE-source rendered block,
|
||||
// in BOTH modes. The webview's selection→source mapping walks the DOM to the
|
||||
// nearest data-src ancestor; these offsets are the contract.
|
||||
describe("F11 data-src emission (INV-36)", () => {
|
||||
/** Pull every data-src-start/end pair from an HTML string, in document order. */
|
||||
const srcRanges = (html: string): Array<{ start: number; end: number }> =>
|
||||
Array.from(html.matchAll(/data-src-start="(\d+)" data-src-end="(\d+)"/g)).map((m) => ({
|
||||
start: Number(m[1]),
|
||||
end: Number(m[2]),
|
||||
}));
|
||||
|
||||
test("renderPlain wraps every block with data-src offsets equal to splitBlocksWithRanges (and stays clean)", () => {
|
||||
const doc = "# Title\n\nFirst para.\n\nSecond para.\n";
|
||||
const blocks = splitBlocksWithRanges(doc);
|
||||
const html = renderPlain(doc);
|
||||
expect(srcRanges(html)).toEqual(blocks.map((b) => ({ start: b.start, end: b.end })));
|
||||
// off-mode stays the clean preview: no annotation marks.
|
||||
expect(html).not.toContain("cw-");
|
||||
// each range slices back to its block's raw source.
|
||||
for (const b of blocks) expect(doc.slice(b.start, b.end)).toBe(b.raw);
|
||||
});
|
||||
|
||||
test("renderReview emits data-src on each live block; removed + proposal blocks carry none (INV-36)", () => {
|
||||
// baseline has an extra paragraph that is REMOVED in current; current adds one.
|
||||
const baseline = "Keep this.\n\nDrop this.\n";
|
||||
const current = "Keep this.\n\nBrand new.\n";
|
||||
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 4, replaced: "Keep", replacement: "Hold" }];
|
||||
const html = renderReview(baseline, current, [], proposals);
|
||||
const liveBlocks = splitBlocksWithRanges(current);
|
||||
// exactly one data-src per LIVE (current-side) block — removed/proposal blocks excluded.
|
||||
expect(srcRanges(html)).toEqual(liveBlocks.map((b) => ({ start: b.start, end: b.end })));
|
||||
// the proposal block itself is not a live-source block.
|
||||
const propIdx = html.indexOf('data-proposal-id="p1"');
|
||||
const propTag = html.slice(html.lastIndexOf("<div", propIdx), propIdx + 1);
|
||||
expect(propTag).not.toContain("data-src-start");
|
||||
});
|
||||
|
||||
test("data-src emission is deterministic — same inputs → identical HTML (extends INV-22)", () => {
|
||||
const a = renderPlain("alpha\n\nbeta\n");
|
||||
const b = renderPlain("alpha\n\nbeta\n");
|
||||
expect(a).toBe(b);
|
||||
const r1 = renderReview("x", "x\n\ny", [], []);
|
||||
const r2 = renderReview("x", "x\n\ny", [], []);
|
||||
expect(r1).toBe(r2);
|
||||
});
|
||||
|
||||
// CHARACTERIZATION (conscious tradeoff of the locked block-level mapping, §6.7
|
||||
// fork 1): both modes render markdown PER BLOCK so each block can carry its
|
||||
// data-src offsets. A consequence is that markdown constructs whose parts span
|
||||
// blank-line-separated blocks — a reference-link USE and its DEFINITION — do not
|
||||
// resolve across blocks (markdown-it sees each block in isolation). renderReview
|
||||
// already had this; F11 brings the off/clean preview into line with it (both
|
||||
// per-block) rather than leaving the two modes rendering differently. If
|
||||
// cross-block fidelity is wanted later, it is a follow-up (source-map driven
|
||||
// wrapping), not a change to the locked block-level decision.
|
||||
test("a reference-link definition in a separate block does not resolve (block-level rendering)", () => {
|
||||
const doc = "See [the spec][ref] for details.\n\n[ref]: https://example.com/spec\n";
|
||||
const html = renderPlain(doc);
|
||||
// the link is NOT resolved to an <a href> — the [text][ref] is rendered literally.
|
||||
expect(html).not.toContain('href="https://example.com/spec"');
|
||||
expect(html).toContain("[the spec][ref]");
|
||||
// both modes agree (renderReview is likewise per-block) — the consistency point.
|
||||
expect(renderReview(doc, doc, [], [])).not.toContain('href="https://example.com/spec"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createTurnProgressState,
|
||||
reduceTurnProgress,
|
||||
formatProgressLine,
|
||||
formatTokens,
|
||||
type TurnProgressSnapshot,
|
||||
} from "../src/turnProgress";
|
||||
|
||||
// Minimal event factories — structurally match the @cline/sdk AgentRuntimeEvent
|
||||
// members the reducer reads. `as any` because we only supply the fields used.
|
||||
const ev = (e: any) => e as any;
|
||||
const textDelta = (text: string, accumulatedText: string) =>
|
||||
ev({ type: "assistant-text-delta", text, accumulatedText });
|
||||
const toolStarted = (toolName: string) => ev({ type: "tool-started", toolCall: { toolName } });
|
||||
const toolFinished = (toolName: string) => ev({ type: "tool-finished", toolCall: { toolName } });
|
||||
const usage = (inputTokens: number, outputTokens: number) =>
|
||||
ev({ type: "usage-updated", usage: { inputTokens, outputTokens, cacheReadTokens: 0, cacheWriteTokens: 0 } });
|
||||
|
||||
// Drive a sequence of events, returning every emitted snapshot.
|
||||
function run(events: any[]): TurnProgressSnapshot[] {
|
||||
let state = createTurnProgressState();
|
||||
const out: TurnProgressSnapshot[] = [];
|
||||
for (const e of events) {
|
||||
const r = reduceTurnProgress(state, e);
|
||||
state = r.state;
|
||||
if (r.snapshot) out.push(r.snapshot);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("reduceTurnProgress", () => {
|
||||
it("starts in thinking", () => {
|
||||
const s = run([ev({ type: "run-started" })]);
|
||||
expect(s.at(-1)!.phase).toBe("thinking");
|
||||
expect(s.at(-1)!.chars).toBe(0);
|
||||
expect(s.at(-1)!.tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("text deltas move to writing and accumulate chars + carry the delta", () => {
|
||||
const s = run([textDelta("Hel", "Hel"), textDelta("lo", "Hello")]);
|
||||
expect(s.map((x) => x.phase)).toEqual(["writing", "writing"]);
|
||||
expect(s.at(-1)!.chars).toBe(5);
|
||||
expect(s.map((x) => x.textDelta)).toEqual(["Hel", "lo"]);
|
||||
});
|
||||
|
||||
it("usage sets a running token total (input+output)", () => {
|
||||
const s = run([textDelta("Hi", "Hi"), usage(1000, 234)]);
|
||||
expect(s.at(-1)!.tokens).toBe(1234);
|
||||
});
|
||||
|
||||
it("tool start shows the tool name; tool finish reverts to writing once text was seen", () => {
|
||||
const s = run([textDelta("x", "x"), toolStarted("read_file"), toolFinished("read_file")]);
|
||||
expect(s[1].phase).toBe("tool");
|
||||
expect(s[1].tool).toBe("read_file");
|
||||
expect(s.at(-1)!.phase).toBe("writing");
|
||||
});
|
||||
|
||||
it("tool finish reverts to thinking when no text was seen", () => {
|
||||
const s = run([toolStarted("grep"), toolFinished("grep")]);
|
||||
expect(s.at(-1)!.phase).toBe("thinking");
|
||||
});
|
||||
|
||||
it("overlapping tools resolve in order", () => {
|
||||
const s = run([toolStarted("a"), toolStarted("b"), toolFinished("b"), toolFinished("a")]);
|
||||
expect(s.map((x) => x.phase)).toEqual(["tool", "tool", "tool", "thinking"]);
|
||||
expect(s[1].tool).toBe("b");
|
||||
expect(s[2].tool).toBe("a");
|
||||
});
|
||||
|
||||
it("reasoning deltas stay thinking and surface no text", () => {
|
||||
const s = run([ev({ type: "assistant-reasoning-delta", text: "secret", accumulatedText: "secret" })]);
|
||||
expect(s.at(-1)!.phase).toBe("thinking");
|
||||
expect(s.at(-1)!.textDelta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores lifecycle/finish events (no snapshot)", () => {
|
||||
const s = run([ev({ type: "turn-finished" }), ev({ type: "run-finished" })]);
|
||||
expect(s).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatProgressLine / formatTokens", () => {
|
||||
it("thinking", () => {
|
||||
expect(formatProgressLine({ phase: "thinking", chars: 0 })).toBe("thinking…");
|
||||
});
|
||||
it("writing with chars", () => {
|
||||
expect(formatProgressLine({ phase: "writing", chars: 412 })).toBe("writing… (412 chars)");
|
||||
});
|
||||
it("writing with chars + tokens", () => {
|
||||
expect(formatProgressLine({ phase: "writing", chars: 412, tokens: 1234 })).toBe(
|
||||
"writing… (412 chars) · 1.2k tokens",
|
||||
);
|
||||
});
|
||||
it("tool with name", () => {
|
||||
expect(formatProgressLine({ phase: "tool", tool: "read_file", chars: 0 })).toBe("running read_file…");
|
||||
});
|
||||
it("formats token magnitudes", () => {
|
||||
expect(formatTokens(950)).toBe("950");
|
||||
expect(formatTokens(1234)).toBe("1.2k");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user