13 Commits

Author SHA1 Message Date
Ben Stull 5645b926a3 #40 (WIP, UNVERIFIED): restore exact author attribution on undo/redo
Follow-up to #38. #38 made undo/redo re-inserted text neutral (no false human
coloring); #40 restores its EXACT prior attribution so Claude's restored text is
blue again, the human's green.

Mechanism — text-keyed attribution snapshots: per-doc Map<documentText, spans>,
snapshotted after every forward edit + at load; on undo/redo, after the #38
geometry reconcile, if a snapshot's text equals the current buffer the spans are
restored exactly (offsets valid — identical text). Robust to VS Code undo
coalescing (only the resulting-text-matching event restores); far-back/evicted
states fall back to #38 neutral. History bounded (ATTR_HISTORY_MAX, oldest
evicted).

NOT MERGED / NOT VERIFIED END-TO-END: 222 unit + typecheck green, but the #40
host E2E (and the pre-existing #38 undoMarks E2E) depend on
executeCommand("undo"), which is broken in this test environment — the untouched
#38 test fails identically on clean main (the known undoMarks flake, now
deterministic). Clearing .vscode-test/user-data (the usual remedy) is
permission-blocked here. The operator should verify in a working E2E environment
before merging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:56:43 -07:00
Ben Stull 64c7d7bad2 claim vscode-cowriting-plugin session 0048 (placeholder) + sessions.json entry 2026-06-13 08:45:48 -07:00
Ben Stull ab00cbefcc add sessions/0047/SESSION-0047.0-TRANSCRIPT-2026-06-13T08-34--2026-06-13T08-44.md + replace placeholder/variant SESSION-0047.0-TRANSCRIPT-2026-06-13T08-34--INPROGRESS.md 2026-06-13 08:43:50 -07:00
benstull c67749a53c Merge pull request '#33: harden author-coloring PUA sentinels against intra-emphasis markdown' (#53) from s33-sentinel-hardening into main 2026-06-13 15:42:04 +00:00
Ben Stull ba7623f813 #33: harden author-coloring PUA sentinels against intra-emphasis markdown
The F9/F10 author-coloring technique injects paired PUA sentinels into prose
source at span offsets, renders, then maps sentinels → cw-by-* spans. Two failure
modes when a span boundary met emphasis markup (characterized in session 0032):
- CASE1: a boundary strictly inside a delimiter run (a**b**c, between the two *)
  split `**`, breaking markdown-it's delimiter pairing (stray <em></em>, raw **).
- CASE3: a boundary inside an emphasis run (**bold** span covering **bo) rendered
  the emphasis but MISNESTED span/element (<strong>bo</span>ld</strong>).

Token-aware fix (both, per the issue #33 comment):
- injectSentinels: clamp any sentinel offset that lands strictly inside a
  delimiter run (* _ ~ `) to the run's start, so a sentinel never splits a run
  (CASE1). Delimiters are invisible once rendered, so this only shifts the colored
  boundary across markup. Drop spans that clamp to empty.
- sentinelsToSpans: replace the naive global split/join with a walker over the
  rendered HTML that emits the cw-by-* span only around TEXT runs — closing it
  before any <tag> and reopening after — so a span is always well-nested within
  inline elements (one span segment per text run, CASE3). Tags are copied
  verbatim with any stray sentinel stripped (no Private-Use-Area char leaks).

Pure, vscode-free, deterministic (INV-33). No regression: existing colorByAuthor /
renderReview cases stay green; the common no-emphasis case is byte-identical.

222 unit + 74/5 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:41:31 -07:00
Ben Stull 9e9fcb8057 claim vscode-cowriting-plugin session 0047 (placeholder) + sessions.json entry 2026-06-13 08:34:03 -07:00
Ben Stull 1381eab11e add sessions/0046/SESSION-0046.0-TRANSCRIPT-2026-06-13T08-19--2026-06-13T08-31.md + replace placeholder/variant SESSION-0046.0-TRANSCRIPT-2026-06-13T08-19--INPROGRESS.md 2026-06-13 08:32:25 -07:00
benstull 54846da1ea Merge pull request '#48: pinning the baseline leaves the review panel fully un-annotated' (#52) from s48-pin-clean-panel into main 2026-06-13 15:30:55 +00:00
Ben Stull fdd743490d #48: pinning the baseline leaves the review panel fully un-annotated
A pinned baseline with no changes since should read as a clean starting point,
but the F10 on-render still author-colored every block (colorByAuthor), painting
the whole document green/blue right after a pin. Now, when the baseline reason is
"pinned" and there are zero changes since (every diffBlocks op unchanged), the
on-render is fully clean — no change marks and no authorship coloring — while the
data-src block mapping (INV-36) and any pending proposals (review actions, not
annotations) are kept.

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 so
accepted Claude text stays blue (F10 INV-33). renderReview takes a `pinned`
option; the controller passes baseline.reason === "pinned" from both refresh and
the renderHtmlFor test seam.

- trackChangesModel.ts: renderReview gains the `pinned` RenderOption; when pinned
  + zero-diff, blocks render plain (no colorByAuthor).
- trackChangesPreview.ts: pass { pinned } through refresh + renderHtmlFor.
- unit: pinned+zero-diff → no cw-by-*; pinned+zero-diff still shows proposals;
  zero-diff WITHOUT pin (machine-landing) keeps coloring (INV-33); real changes
  after a pin re-color.
- s48PinClean host E2E: type → colored; pin → clean; edit → annotations return.

218 unit + 74/5 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:28:52 -07:00
Ben Stull 4584e06679 claim vscode-cowriting-plugin session 0046 (placeholder) + sessions.json entry 2026-06-13 08:19:43 -07:00
Ben Stull ef07acfdc1 add sessions/0045/SESSION-0045.0-TRANSCRIPT-2026-06-13T08-04--2026-06-13T08-17.md + replace placeholder/variant SESSION-0045.0-TRANSCRIPT-2026-06-13T08-04--INPROGRESS.md 2026-06-13 08:18:17 -07:00
benstull 83c4a80d8b Merge pull request '#46 (SLICE-3, accept): Accept all pending proposals in one gesture' (#51) from f12-slice3-accept-all into main 2026-06-13 15:16:18 +00:00
Ben Stull c94b9ccfe7 #46 (SLICE-3, accept): Accept all pending proposals in one gesture
Document-edit flow SLICE-3 — completes reach→review→accept
(specs/coauthoring-document-edit-flow.md §7.2, INV-42). 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 word-precise path
automatically), in descending anchor order so an earlier accept never invalidates
a later one, skipping (never force-applying) proposals that can't anchor and
reporting applied-vs-skipped. Batched application of the existing accept path — no
new mechanism; the webview posts intent only (INV-35). No confirmation dialog
(undo restores).

- proposalController.ts: acceptAllProposals(document) → {applied, skipped}
  (descending order, orphan-skip); accept/acceptById gain a silent opt so the
  batch suppresses N per-proposal orphan warnings in favour of one report.
- trackChangesPreview.ts: ToolbarMsg += {type:"acceptAll"}; handleWebviewMessage
  routes it to a public acceptAll(document) that batches + reports.
- extension.ts + package.json: cowriting.acceptAllProposals command (active doc,
  markdown-gated palette entry) for the non-webview path.
- media/preview.ts + shellHtml: "✓✓ Accept all" toolbar button, posting the
  intent, shown only with ≥2 pending proposals (authorable, on-state).
- trackChangesModel.ts: diffToBlockHunks now emits one block-aligned hunk per
  CHANGED block even when changed blocks are ADJACENT (treats changed blocks as
  1:1 anchors alongside unchanged ones; gap-spans only cover add/remove runs
  between anchors) — fixes adjacent changed blocks collapsing into one proposal.
- f12Accept host E2E (apply-all reconstructs; orphan skip + report; single
  proposal; command registered/gated); MANUAL-SMOKE-F12 §3.

214 unit + 73/5 host E2E green. Completes the document-edit-flow cluster
(#42 reach + #47 review + #46 accept).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:15:55 -07:00
17 changed files with 984 additions and 53 deletions
+27
View File
@@ -67,3 +67,30 @@ light copy-edit instruction (e.g. "tighten the prose, fix typos").
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 01 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.
+9
View File
@@ -30,6 +30,7 @@ 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", () => {
@@ -88,6 +89,11 @@ askEl?.addEventListener("click", () => {
}
});
// #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");
@@ -132,6 +138,9 @@ window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
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;
+9
View File
@@ -83,6 +83,11 @@
"command": "cowriting.editDocument",
"title": "Ask Claude to Edit Document",
"category": "Cowriting"
},
{
"command": "cowriting.acceptAllProposals",
"title": "Accept All Claude Proposals",
"category": "Cowriting"
}
],
"menus": {
@@ -110,6 +115,10 @@
{
"command": "cowriting.editDocument",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.acceptAllProposals",
"when": "editorLangId == markdown"
}
],
"editor/title": [
@@ -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.)
@@ -1,18 +1,18 @@
# Session 0045.0 — Transcript
# Session 0048.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T08-04 (PST)
> Start: 2026-06-13T08-45 (PST)
> Type: planning-and-executing
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0045 for vscode-cowriting-plugin. The driver replaces this
> This file reserves session ID 0048 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0045.0-TRANSCRIPT-2026-06-13T08-04--<end>.md form at session end.
> SESSION-0048.0-TRANSCRIPT-2026-06-13T08-45--<end>.md form at session end.
## 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
/goal plan-and-execute #40 (restore exact author attribution on undo/redo — follow-up to #38)
```
+9
View File
@@ -133,5 +133,14 @@
},
"0045": {
"title": ""
},
"0046": {
"title": ""
},
"0047": {
"title": ""
},
"0048": {
"title": ""
}
}
+38 -1
View File
@@ -44,8 +44,19 @@ interface DocAttribution {
* orphans on reload).
*/
hadAttributions: boolean;
/**
* #40: text-keyed attribution snapshots for exact provenance restoration on
* undo/redo. Maps a document-text state the live spans at that state (offsets
* valid for that exact text). Captured after every forward edit + at load;
* consulted on undo/redo to restore the matching state's spans. Bounded (oldest
* evicted) a far-back/evicted state falls back to the #38 neutral reconcile.
*/
attrHistory: Map<string, LiveSpan[]>;
}
/** #40: cap on the per-doc attribution-snapshot history (oldest evicted). */
const ATTR_HISTORY_MAX = 200;
export class AttributionController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocAttribution>();
@@ -90,7 +101,7 @@ export class AttributionController implements vscode.Disposable {
private state(docPath: string): DocAttribution {
let s = this.docs.get(docPath);
if (!s) {
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false };
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false, attrHistory: new Map() };
this.docs.set(docPath, s);
}
return s;
@@ -125,6 +136,9 @@ export class AttributionController implements vscode.Disposable {
s.spans = coalesce(s.spans);
if (artifact.attributions.length > 0) s.hadAttributions = true;
}
// #40: seed the snapshot history with the loaded state so undoing back to it
// restores its exact attribution.
this.snapshotAttribution(s, document.getText());
this.render(document);
}
@@ -140,6 +154,17 @@ export class AttributionController implements vscode.Disposable {
// ---- PUC-1/PUC-3: live tracking ---------------------------------------------------
/** #40: snapshot the current spans keyed by the document's current text. */
private snapshotAttribution(s: DocAttribution, text: string): void {
s.attrHistory.delete(text); // re-insert at the end (recency order)
s.attrHistory.set(text, s.spans.map((sp) => ({ ...sp })));
while (s.attrHistory.size > ATTR_HISTORY_MAX) {
const oldest = s.attrHistory.keys().next().value as string | undefined;
if (oldest === undefined) break;
s.attrHistory.delete(oldest);
}
}
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
const docPath = this.keyOf(e.document);
@@ -202,6 +227,18 @@ export class AttributionController implements vscode.Disposable {
);
}
}
// #40: exact provenance across history navigation. After a FORWARD edit,
// snapshot the new state's spans. On UNDO/REDO, the geometry reconcile above
// left re-inserted text neutral (#38); if a snapshot's text equals the current
// buffer, restore that state's spans exactly (offsets are valid — identical
// text) so Claude's restored text is blue again, the human's green. No match
// (far-back/evicted state) keeps the #38 neutral fallback.
if (isUndoRedo) {
const restored = s.attrHistory.get(e.document.getText());
if (restored) s.spans = restored.map((sp) => ({ ...sp }));
} else {
this.snapshotAttribution(s, e.document.getText());
}
if (s.spans.length > 0) s.hadAttributions = true;
this.render(e.document);
}
+14
View File
@@ -107,6 +107,20 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
);
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.
+43 -9
View File
@@ -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,9 +189,11 @@ 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;
}
@@ -178,9 +211,10 @@ export class ProposalController implements vscode.Disposable {
{ 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));
+135 -36
View File
@@ -337,14 +337,16 @@ function alignBlocks(cur: BlockWithRange[], next: BlockWithRange[]): BlockAlignO
* 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`:
* - an ISOLATED changed block (1:1, unchanged blocks on both sides) a
* - 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).
* - any other changed RUN (block insertions/deletions, or adjacent changes) one
* gap-span hunk covering the source between the bounding unchanged blocks 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.
* 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
@@ -359,34 +361,39 @@ export function diffToBlockHunks(currentText: string, rewrittenText: string): Ed
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) {
if (ops[i].kind === "unchanged") {
const op = ops[i];
if (op.kind === "unchanged") {
i++;
continue;
}
let j = i;
while (j < ops.length && ops[j].kind !== "unchanged") j++;
const run = ops.slice(i, j);
if (run.length === 1 && run[0].kind === "changed") {
const c = cur[run[0].ci!];
hunks.push({ start: c.start, end: c.end, replacement: next[run[0].ni!].raw });
} else {
// A run with insertions/deletions: replace the whole inter-anchor gap so the
// separators reconstruct exactly. Bound by the unchanged blocks on each side
// (or the document edges).
const prev = i > 0 ? ops[i - 1] : null;
const after = j < ops.length ? ops[j] : null;
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);
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;
}
@@ -434,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 {
@@ -530,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 });
}
@@ -549,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
// &lt;, 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("");
}
/**
@@ -666,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
@@ -696,7 +795,7 @@ 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);
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));
+25 -2
View File
@@ -37,7 +37,8 @@ type ToolbarMsg =
| { type: "reject"; proposalId: string }
| { type: "pinBaseline" }
| { type: "askClaude"; scope: "document" }
| { type: "askClaude"; scope: "selection"; start: number; end: number };
| { type: "askClaude"; scope: "selection"; start: number; end: number }
| { type: "acceptAll" };
export class TrackChangesPreviewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
@@ -190,9 +191,29 @@ export class TrackChangesPreviewController implements vscode.Disposable {
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
@@ -316,7 +337,7 @@ 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,
@@ -392,6 +413,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
<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>
@@ -442,6 +464,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). */
+128
View File
@@ -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");
});
});
+113
View File
@@ -0,0 +1,113 @@
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));
const AGENT = { kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-s40" } };
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, "exports attribution");
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() };
}
// #40 (follow-up to #38): on undo/redo, restore each re-inserted char's EXACT
// prior author attribution rather than leaving it neutral (#38). Driven mid-edit
// (buffer dirty) so the attribution branch runs, not the disk-sync one.
suite("F3 #40 — undo/redo restores exact author attribution (host E2E, no LLM)", () => {
test("undo of a deletion of AGENT text restores its agent span (not neutral, not human)", async () => {
const { doc } = await freshDoc("docs/s40agent.md", "Human start. xxxx end.\n");
const api = await getApi();
const ctl = api.attributionController;
// Make "xxxx" agent-authored via the seam → an agent span over "ROBOT".
const t = "xxxx";
const start = doc.getText().indexOf(t);
const ok = await ctl.applyAgentEdit(
doc,
new vscode.Range(doc.positionAt(start), doc.positionAt(start + t.length)),
"ROBOT",
AGENT,
{ turnId: "turn-s40" },
);
assert.strictEqual(ok, true, "seam edit applies");
await settle();
const key = api.proposalController.keyFor(doc);
assert.ok(
ctl.getSpans(key).some((s) => s.authorKind === "agent" && doc.getText().slice(s.range.start, s.range.end).includes("ROBOT")),
"ROBOT is agent-attributed after the seam edit",
);
// Human deletes "ROBOT" (buffer stays dirty: it already diverged from disk).
const rs = doc.getText().indexOf("ROBOT");
const del = new vscode.WorkspaceEdit();
del.delete(doc.uri, new vscode.Range(doc.positionAt(rs), doc.positionAt(rs + "ROBOT".length)));
assert.ok(await vscode.workspace.applyEdit(del), "delete applied");
await settle();
assert.ok(!doc.getText().includes("ROBOT"), "ROBOT deleted");
// Undo the deletion → ROBOT re-inserted. #40: its AGENT span is restored.
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("undo");
await settle();
assert.ok(doc.getText().includes("ROBOT"), "undo restored ROBOT");
assert.ok(doc.isDirty, "buffer still dirty → attribution branch ran");
const r2 = doc.getText().indexOf("ROBOT");
const spans = ctl.getSpans(key);
const over = spans.filter((s) => s.range.start < r2 + 5 && s.range.end > r2);
assert.ok(over.length > 0, "restored ROBOT carries a span");
assert.ok(over.every((s) => s.authorKind === "agent"), `restored ROBOT is agent-attributed, got ${JSON.stringify(over)}`);
});
test("edit → undo → redo round-trips attribution to identical state", async () => {
const { doc } = await freshDoc("docs/s40roundtrip.md", "Base alpha. yyyy omega.\n");
const api = await getApi();
const ctl = api.attributionController;
const key = api.proposalController.keyFor(doc);
const start = doc.getText().indexOf("yyyy");
await ctl.applyAgentEdit(
doc,
new vscode.Range(doc.positionAt(start), doc.positionAt(start + 4)),
"BLUEWORD",
AGENT,
{ turnId: "turn-s40b" },
);
await settle();
const norm = (k: string) =>
ctl
.getSpans(k)
.map((s) => ({ a: s.authorKind, t: doc.getText().slice(s.range.start, s.range.end) }))
.sort((x, y) => (x.t < y.t ? -1 : 1));
const afterAgent = JSON.stringify(norm(key));
assert.ok(afterAgent.includes("BLUEWORD") && afterAgent.includes("agent"), "agent span present after the edit");
// Delete BLUEWORD, then undo (restore) then redo (re-delete) then undo again.
const bs = doc.getText().indexOf("BLUEWORD");
const del = new vscode.WorkspaceEdit();
del.delete(doc.uri, new vscode.Range(doc.positionAt(bs), doc.positionAt(bs + "BLUEWORD".length)));
await vscode.workspace.applyEdit(del);
await settle();
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("undo"); // restore BLUEWORD
await settle();
assert.ok(doc.getText().includes("BLUEWORD"), "undo restored BLUEWORD");
assert.strictEqual(JSON.stringify(norm(key)), afterAgent, "attribution after undo matches the pre-deletion state exactly");
});
});
+63
View File
@@ -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");
});
});
+87
View File
@@ -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[] = [