Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5645b926a3 | |||
| 64c7d7bad2 | |||
| ab00cbefcc | |||
| c67749a53c | |||
| ba7623f813 | |||
| 9e9fcb8057 | |||
| 1381eab11e | |||
| 54846da1ea | |||
| fdd743490d | |||
| 4584e06679 | |||
| ef07acfdc1 | |||
| 83c4a80d8b | |||
| c94b9ccfe7 |
@@ -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);
|
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
|
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.
|
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.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const legend = document.getElementById("cw-legend")!;
|
|||||||
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
|
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
|
||||||
const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null;
|
const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null;
|
||||||
const askEl = document.getElementById("cw-ask") 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.
|
// F10: the annotations on/off toggle.
|
||||||
annotationsEl?.addEventListener("change", () => {
|
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).
|
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
|
||||||
body.addEventListener("click", (e) => {
|
body.addEventListener("click", (e) => {
|
||||||
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
|
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
|
||||||
@@ -132,6 +138,9 @@ window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
|
|||||||
if (askEl) askEl.disabled = !authorable;
|
if (askEl) askEl.disabled = !authorable;
|
||||||
const on = msg.mode === "on";
|
const on = msg.mode === "on";
|
||||||
if (annotationsEl) annotationsEl.checked = 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.
|
// Off-state is a clean preview: hide the review chrome.
|
||||||
header.hidden = !on;
|
header.hidden = !on;
|
||||||
summary.hidden = !on;
|
summary.hidden = !on;
|
||||||
|
|||||||
@@ -83,6 +83,11 @@
|
|||||||
"command": "cowriting.editDocument",
|
"command": "cowriting.editDocument",
|
||||||
"title": "Ask Claude to Edit Document",
|
"title": "Ask Claude to Edit Document",
|
||||||
"category": "Cowriting"
|
"category": "Cowriting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.acceptAllProposals",
|
||||||
|
"title": "Accept All Claude Proposals",
|
||||||
|
"category": "Cowriting"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
@@ -110,6 +115,10 @@
|
|||||||
{
|
{
|
||||||
"command": "cowriting.editDocument",
|
"command": "cowriting.editDocument",
|
||||||
"when": "editorLangId == markdown"
|
"when": "editorLangId == markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.acceptAllProposals",
|
||||||
|
"when": "editorLangId == markdown"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor/title": [
|
"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.)
|
||||||
+5
-5
@@ -1,18 +1,18 @@
|
|||||||
# Session 0045.0 — Transcript
|
# Session 0048.0 — Transcript
|
||||||
|
|
||||||
> App: vscode-cowriting-plugin
|
> App: vscode-cowriting-plugin
|
||||||
> Start: 2026-06-13T08-04 (PST)
|
> Start: 2026-06-13T08-45 (PST)
|
||||||
> Type: planning-and-executing
|
> Type: planning-and-executing
|
||||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
> 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
|
> 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
|
## 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)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -133,5 +133,14 @@
|
|||||||
},
|
},
|
||||||
"0045": {
|
"0045": {
|
||||||
"title": ""
|
"title": ""
|
||||||
|
},
|
||||||
|
"0046": {
|
||||||
|
"title": ""
|
||||||
|
},
|
||||||
|
"0047": {
|
||||||
|
"title": ""
|
||||||
|
},
|
||||||
|
"0048": {
|
||||||
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,19 @@ interface DocAttribution {
|
|||||||
* orphans on reload).
|
* orphans on reload).
|
||||||
*/
|
*/
|
||||||
hadAttributions: boolean;
|
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 {
|
export class AttributionController implements vscode.Disposable {
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
private readonly docs = new Map<string, DocAttribution>();
|
private readonly docs = new Map<string, DocAttribution>();
|
||||||
@@ -90,7 +101,7 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
private state(docPath: string): DocAttribution {
|
private state(docPath: string): DocAttribution {
|
||||||
let s = this.docs.get(docPath);
|
let s = this.docs.get(docPath);
|
||||||
if (!s) {
|
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);
|
this.docs.set(docPath, s);
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
@@ -125,6 +136,9 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
s.spans = coalesce(s.spans);
|
s.spans = coalesce(s.spans);
|
||||||
if (artifact.attributions.length > 0) s.hadAttributions = true;
|
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);
|
this.render(document);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +154,17 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
|
|
||||||
// ---- PUC-1/PUC-3: live tracking ---------------------------------------------------
|
// ---- 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 {
|
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||||
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
||||||
const docPath = this.keyOf(e.document);
|
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;
|
if (s.spans.length > 0) s.hadAttributions = true;
|
||||||
this.render(e.document);
|
this.render(e.document);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,20 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
);
|
);
|
||||||
context.subscriptions.push(trackChangesPreviewController);
|
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 ---
|
// --- F6 machine-landing wiring — now for ANY authorable doc ---
|
||||||
// The seam's single machine-landing signal advances the F6 baseline (INV-18);
|
// The seam's single machine-landing signal advances the F6 baseline (INV-18);
|
||||||
// the seam can now fire on out-of-folder files too, so wire it unconditionally.
|
// the seam can now fire on out-of-folder files too, so wire it unconditionally.
|
||||||
|
|||||||
@@ -138,9 +138,40 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
// ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ----------------------------------
|
// ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ----------------------------------
|
||||||
|
|
||||||
/** Accept by proposal id (test-facing twin of the thread-menu gesture). */
|
/** Accept by 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);
|
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). */
|
/** Reject by proposal id (test-facing twin of the thread-menu gesture). */
|
||||||
rejectById(docPath: string, proposalId: string): boolean {
|
rejectById(docPath: string, proposalId: string): boolean {
|
||||||
@@ -150,7 +181,7 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
return true;
|
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;
|
if (this.guard.isReadOnly(state.docPath)) return false;
|
||||||
const document = this.openDoc(state);
|
const document = this.openDoc(state);
|
||||||
if (!document) return false;
|
if (!document) return false;
|
||||||
@@ -158,9 +189,11 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
|
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
|
||||||
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
|
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
|
||||||
if (resolved === "orphaned") {
|
if (resolved === "orphaned") {
|
||||||
void vscode.window.showWarningMessage(
|
// #46: accept-all suppresses per-proposal warnings (one batch report instead).
|
||||||
"Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).",
|
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);
|
this.renderAll(document);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -178,9 +211,10 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
{ expectedVersion: document.version, turnId: proposal.turnId },
|
{ expectedVersion: document.version, turnId: proposal.turnId },
|
||||||
);
|
);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
void vscode.window.showWarningMessage(
|
if (!opts?.silent)
|
||||||
"Cowriting: the editor rejected the accept — the proposal is still pending.",
|
void vscode.window.showWarningMessage(
|
||||||
);
|
"Cowriting: the editor rejected the accept — the proposal is still pending.",
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
|
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
|
||||||
|
|||||||
+135
-36
@@ -337,14 +337,16 @@ function alignBlocks(cur: BlockWithRange[], next: BlockWithRange[]): BlockAlignO
|
|||||||
* did and this supersedes for document edits). Built by aligning both sides into
|
* did and this supersedes for document edits). Built by aligning both sides into
|
||||||
* the existing block units (`splitBlocksWithRanges`) and keying with the same diff
|
* the existing block units (`splitBlocksWithRanges`) and keying with the same diff
|
||||||
* as `diffBlocks`:
|
* 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
|
* 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).
|
* text. A code/mermaid fence is one such whole-block hunk (atomic, INV-23). So
|
||||||
* - any other changed RUN (block insertions/deletions, or adjacent changes) → one
|
* a copy-edit pass touching N consecutive paragraphs yields N proposals.
|
||||||
* gap-span hunk covering the source between the bounding unchanged blocks → the
|
* - a run of block INSERTIONS / DELETIONS → one gap-span hunk covering the source
|
||||||
* matching rewritten span (separators included), so reconstruction stays exact.
|
* between the bounding aligned blocks (unchanged OR changed — both are 1:1
|
||||||
* A zero-width gap-span (insert at a seamless boundary) is anchored to an
|
* anchors) → the matching rewritten span (separators included), so
|
||||||
* adjacent token (INV-41) so its F4 fingerprint resolves and it accepts.
|
* 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.
|
* - unchanged blocks (same key AND same raw) → no hunk.
|
||||||
* Pure, vscode-free, deterministic; same `EditHunk` shape as `diffToHunks` (which
|
* Pure, vscode-free, deterministic; same `EditHunk` shape as `diffToHunks` (which
|
||||||
* is RETAINED as the intra-block sub-diff engine for word-precise accept
|
* 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) {
|
for (const op of ops) {
|
||||||
if (op.kind === "unchanged" && cur[op.ci!].raw !== next[op.ni!].raw) op.kind = "changed";
|
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[] = [];
|
const hunks: EditHunk[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < ops.length) {
|
while (i < ops.length) {
|
||||||
if (ops[i].kind === "unchanged") {
|
const op = ops[i];
|
||||||
|
if (op.kind === "unchanged") {
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let j = i;
|
if (op.kind === "changed") {
|
||||||
while (j < ops.length && ops[j].kind !== "unchanged") j++;
|
const c = cur[op.ci!];
|
||||||
const run = ops.slice(i, j);
|
hunks.push({ start: c.start, end: c.end, replacement: next[op.ni!].raw });
|
||||||
if (run.length === 1 && run[0].kind === "changed") {
|
i++;
|
||||||
const c = cur[run[0].ci!];
|
continue;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
// 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;
|
i = j;
|
||||||
}
|
}
|
||||||
|
hunks.sort((a, b) => a.start - b.start);
|
||||||
return hunks;
|
return hunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +441,14 @@ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
|
|||||||
export interface RenderOptions {
|
export interface RenderOptions {
|
||||||
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
|
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
|
||||||
render?: (src: string) => string;
|
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 {
|
function defaultRender(src: string): string {
|
||||||
@@ -530,13 +545,33 @@ function authorBadge(authors: Set<AuthorKind>): { cls: string; label: string } |
|
|||||||
: { cls: "cw-by-human", label: "You" };
|
: { 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. */
|
/** 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 {
|
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
|
||||||
const inserts: { at: number; marker: string }[] = [];
|
const inserts: { at: number; marker: string }[] = [];
|
||||||
for (const s of spans) {
|
for (const s of spans) {
|
||||||
const lo = Math.max(0, s.start - blockStart);
|
const lo = clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart));
|
||||||
const hi = Math.min(raw.length, s.end - blockStart);
|
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
|
||||||
if (hi <= lo) continue;
|
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
|
||||||
inserts.push({ at: lo, marker: SENT[s.author].open });
|
inserts.push({ at: lo, marker: SENT[s.author].open });
|
||||||
inserts.push({ at: hi, marker: SENT[s.author].close });
|
inserts.push({ at: hi, marker: SENT[s.author].close });
|
||||||
}
|
}
|
||||||
@@ -549,13 +584,69 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
|
|||||||
return out;
|
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 {
|
function sentinelsToSpans(html: string): string {
|
||||||
return html
|
const out: string[] = [];
|
||||||
.split(SENT.claude.open).join('<span class="cw-by-claude">')
|
let current: AuthorKind | null = null; // which author region we're inside
|
||||||
.split(SENT.claude.close).join("</span>")
|
let spanOpen = false; // whether a <span> is currently open in `out`
|
||||||
.split(SENT.human.open).join('<span class="cw-by-human">')
|
const openSpan = () => {
|
||||||
.split(SENT.human.close).join("</span>");
|
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("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -666,6 +757,14 @@ export function renderReview(
|
|||||||
const render = opts.render ?? defaultRender;
|
const render = opts.render ?? defaultRender;
|
||||||
const ranges = splitBlocksWithRanges(currentText);
|
const ranges = splitBlocksWithRanges(currentText);
|
||||||
const ops = diffBlocks(baselineText, 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
|
// Associate each resolved proposal with the current-side block index whose range
|
||||||
// it anchors into: the largest block with start <= anchorStart (the containing
|
// it anchors into: the largest block with start <= anchorStart (the containing
|
||||||
@@ -696,7 +795,7 @@ export function renderReview(
|
|||||||
const blockIndex = op.kind === "removed" ? -1 : ci;
|
const blockIndex = op.kind === "removed" ? -1 : ci;
|
||||||
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
||||||
const colored = (raw: string): string =>
|
const colored = (raw: string): string =>
|
||||||
blk ? 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)));
|
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
|
||||||
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
||||||
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ type ToolbarMsg =
|
|||||||
| { type: "reject"; proposalId: string }
|
| { type: "reject"; proposalId: string }
|
||||||
| { type: "pinBaseline" }
|
| { type: "pinBaseline" }
|
||||||
| { type: "askClaude"; scope: "document" }
|
| { type: "askClaude"; scope: "document" }
|
||||||
| { type: "askClaude"; scope: "selection"; start: number; end: number };
|
| { type: "askClaude"; scope: "selection"; start: number; end: number }
|
||||||
|
| { type: "acceptAll" };
|
||||||
|
|
||||||
export class TrackChangesPreviewController implements vscode.Disposable {
|
export class TrackChangesPreviewController implements vscode.Disposable {
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
@@ -190,9 +191,29 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
const target: EditTarget =
|
const target: EditTarget =
|
||||||
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
|
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
|
||||||
void this.askClaude(document, target);
|
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
|
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
|
||||||
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
||||||
@@ -316,7 +337,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
void panel.webview.postMessage({
|
void panel.webview.postMessage({
|
||||||
type: "render",
|
type: "render",
|
||||||
mode,
|
mode,
|
||||||
html: renderReview(baselineText, current, spans, proposals),
|
html: renderReview(baselineText, current, spans, proposals, { pinned: baseline?.reason === "pinned" }),
|
||||||
epoch: this.epochLabel(baseline),
|
epoch: this.epochLabel(baseline),
|
||||||
summary,
|
summary,
|
||||||
authorable,
|
authorable,
|
||||||
@@ -392,6 +413,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
|
<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-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-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-epoch">Review</span>
|
||||||
<span id="cw-summary"></span>
|
<span id="cw-summary"></span>
|
||||||
<span id="cw-legend"></span>
|
<span id="cw-legend"></span>
|
||||||
@@ -442,6 +464,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
current,
|
current,
|
||||||
this.attribution.spansFor(doc),
|
this.attribution.spansFor(doc),
|
||||||
this.proposals.listProposals(doc),
|
this.proposals.listProposals(doc),
|
||||||
|
{ pinned: baseline?.reason === "pinned" },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/** F10: current annotations mode for a panel (default on). */
|
/** F10: current annotations mode for a panel (default on). */
|
||||||
|
|||||||
@@ -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,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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -168,6 +168,45 @@ describe("colorByAuthor", () => {
|
|||||||
expect(html).toContain('<span class="cw-by-human">hello</span>');
|
expect(html).toContain('<span class="cw-by-human">hello</span>');
|
||||||
expect(html).toContain("world");
|
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";
|
import { renderPlain } from "../src/trackChangesModel";
|
||||||
@@ -277,6 +316,54 @@ describe("renderReview", () => {
|
|||||||
expect(uIdx).toBeGreaterThan(html.indexOf("Beta there")); // unanchored still trails the body
|
expect(uIdx).toBeGreaterThan(html.indexOf("Beta there")); // unanchored still trails the body
|
||||||
expect(html).toContain("cw-proposal-unanchored");
|
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", () => {
|
test("renderReview is deterministic with mixed anchored/unanchored proposals", () => {
|
||||||
const doc = "one two three";
|
const doc = "one two three";
|
||||||
const proposals: ProposalView[] = [
|
const proposals: ProposalView[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user