add spec ./specs/coauthoring-document-edit-flow.md (status: graduated)
This commit is contained in:
@@ -0,0 +1,592 @@
|
|||||||
|
---
|
||||||
|
status: graduated
|
||||||
|
---
|
||||||
|
# Solution Design: The Ask-Claude Document-Edit Flow (#42 · #47 · #46)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| **Author(s)** | Ben Stull (with Claude) |
|
||||||
|
| **Reviewers / approvers** | Ben Stull |
|
||||||
|
| **Status** | `draft` |
|
||||||
|
| **Version** | v0.1.0 |
|
||||||
|
| **Source artifacts** | Features `benstull/vscode-cowriting-plugin#42` (Ask Claude to Edit Document, `type/feature`, `priority/P2`) · `#47` (block-granularity proposals, `type/feature`, `priority/P1`) · `#46` (accept-all, `type/feature`, `priority/P2`) · Epic `#1` (closed) · Capture sessions `vscode-cowriting-plugin-0035` (#42), `0040` (#47), `0039` (#46) · Brainstorming session `vscode-cowriting-plugin-0041` · Builds on (all shipped): F4 `#12` (propose/accept seam), F10 `#29` (interactive review preview), F11 `#43` (preview toolbar + `editDocument`/`runEditAndPropose`) · Parent specs (graduated): `coauthoring-propose-accept.md`, `coauthoring-attribution.md`, `coauthoring-interactive-review.md`, `coauthoring-rendered-preview.md` · F11 design currently lives in code + issue draft `issues/preview-toolbar-interaction-surface.md` (un-graduated — see §9) · Lineage: `ben.stull/rfc-app#48` |
|
||||||
|
|
||||||
|
**Change log**
|
||||||
|
|
||||||
|
| Date | Version | Change | By |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 2026-06-12 | v0.1.0 | Initial draft — brainstorming session 0041. One combined design for the document-edit cluster, sequencing #42 → #47 → #46. Two forks locked with the operator: per-block proposals **preserve unchanged-span attribution** (block = decision unit, word = attribution unit); the cluster ships as **one** Solution Design. | Ben Stull + Claude |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Business Context
|
||||||
|
|
||||||
|
### 1.1 Executive Summary
|
||||||
|
|
||||||
|
The plugin can already ask Claude to edit a whole document (F11 shipped
|
||||||
|
`cowriting.editDocument` → `runEditAndPropose({kind:"document"})`), but the flow
|
||||||
|
around that capability is rough on three sides, each captured as its own feature:
|
||||||
|
|
||||||
|
- **You can't easily reach it** (#42). "Ask Claude to Edit" only appears in the
|
||||||
|
editor body **when text is selected**. Right-clicking the body with *no*
|
||||||
|
selection, or right-clicking the editor **tab**, offers nothing — two natural
|
||||||
|
"do something with this document" gestures hit a dead end.
|
||||||
|
- **What comes back is unreviewable** (#47, **P1**). A whole-document rewrite is
|
||||||
|
diffed at **word** granularity (`diffToHunks`, INV-37): a light copy-edit pass
|
||||||
|
explodes into dozens of tiny blue ✓/✗ blocks, forcing the reviewer to
|
||||||
|
adjudicate word-fragments out of the sentence they live in.
|
||||||
|
- **Taking the result is tedious** (#46). Accepting Claude's edits is **one
|
||||||
|
proposal at a time**; a document a writer wants wholesale still demands a click
|
||||||
|
per proposal.
|
||||||
|
|
||||||
|
This design treats the three as **one flow** — **reach → review → accept** — and
|
||||||
|
ships them in that order. **#42** makes the document edit reachable from the
|
||||||
|
obvious gestures; **#47** cuts the rewrite into **one proposal per changed block**
|
||||||
|
(paragraph / header / bullet / fence) so the reviewer decides in the unit they
|
||||||
|
think in; **#46** adds a single **Accept all** gesture. The whole flow is built on
|
||||||
|
the existing F4 propose/accept seam and the F10/F11 preview — **no new edit path,
|
||||||
|
no new accept mechanism, no new persisted model.**
|
||||||
|
|
||||||
|
### 1.2 Background
|
||||||
|
|
||||||
|
The inner loop shipped F2–F5 (threads · attribution · propose/accept ·
|
||||||
|
cross-rung). F10 (`#29`) collapsed the review surfaces into one: a **clean
|
||||||
|
editor** + the **rendered markdown preview** as the single annotated review
|
||||||
|
surface, with ✓/✗ acting only on pending F4 proposals (INV-32..34). F11 (`#43`)
|
||||||
|
made the preview's **toolbar the primary interaction surface** — a **Pin
|
||||||
|
baseline** button and an **adaptive Ask-Claude** button — and, as part of that,
|
||||||
|
added the host-side **`cowriting.editDocument`** command and the
|
||||||
|
**`runEditAndPropose`** path that diffs a whole-document rewrite into per-hunk F4
|
||||||
|
proposals (INV-35 sealed-webview intent, INV-36 `data-src` block→source mapping,
|
||||||
|
INV-37 word-level per-hunk granularity).
|
||||||
|
|
||||||
|
So the *capability* exists; this cluster is about the **experience around it**.
|
||||||
|
Three captures landed it:
|
||||||
|
|
||||||
|
- **0035 → #42**: "Add 'Ask Claude to Edit Document' on right-click with no
|
||||||
|
selection; same Edit-Selection/Edit-Document on the tab."
|
||||||
|
- **0040 → #47**: "A single change should be a paragraph (or header, or
|
||||||
|
bulletpoint), not individual words. It's too much to review."
|
||||||
|
- **0039 → #46**: "We need a way to accept all of Claude's edits."
|
||||||
|
|
||||||
|
The captures themselves flagged that these "form an edit-flow cluster — brainstorm
|
||||||
|
/ sequence together," which this session does.
|
||||||
|
|
||||||
|
### 1.3 Business Actors / Roles
|
||||||
|
|
||||||
|
- **Coauthor (human)** — the writer/engineer; the sole user of every gesture
|
||||||
|
here (right-click to ask, ✓/✗ to adjudicate, Accept-all to commit).
|
||||||
|
- **Coauthor (machine)** — Claude via `@cline/sdk`; produces the document
|
||||||
|
rewrite that becomes block-level proposals; its accepted blocks land
|
||||||
|
Claude-attributed (word-precise — §6, INV-40).
|
||||||
|
|
||||||
|
### 1.4 Problem Statement
|
||||||
|
|
||||||
|
How do we make "ask Claude to revise this whole document, look at what it did,
|
||||||
|
and take the parts I want" a **fluid, reviewable, low-friction** loop — when today
|
||||||
|
it is **hard to invoke, exploded into word-fragments, and accepted one click at a
|
||||||
|
time** — **without** forking a second edit path, a second accept mechanism, or a
|
||||||
|
new persisted artifact?
|
||||||
|
|
||||||
|
### 1.5 Pain Points
|
||||||
|
|
||||||
|
| # | Pain | Felt by | Today |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| P-1 | No whole-document ask from the body (no selection) or the tab | Mouse-first / tab-oriented writers | Dead-end gestures |
|
||||||
|
| P-2 | A document edit fans out into many word-level proposals | Anyone reviewing a document edit (worse the lighter the edit) | "Too much to review" (#47, P1) |
|
||||||
|
| P-3 | Accepting many proposals is one click each | Writers who want Claude's whole pass | Manual sweep of ✓ |
|
||||||
|
| P-4 | Block-level accept could lose attribution fidelity inside a block | The product's F3 attribution value | (a design risk this spec resolves — INV-40) |
|
||||||
|
|
||||||
|
### 1.6 Targeted Business Outcomes
|
||||||
|
|
||||||
|
- A **consistent "Ask Claude to Edit"** entry point — selection-scoped with a
|
||||||
|
selection, whole-document without — from both the body and the tab.
|
||||||
|
- A document edit produces a **small number of in-context, block-sized**
|
||||||
|
decisions instead of a flood of word-fragments.
|
||||||
|
- **"Take Claude's whole pass"** is a **single gesture**.
|
||||||
|
- Attribution stays **word-precise** even though the decision unit is the block.
|
||||||
|
|
||||||
|
### 1.7 Scope (business)
|
||||||
|
|
||||||
|
**In scope:** the three features as one sequenced flow — entry-point wiring
|
||||||
|
(#42); document-edit proposal granularity word→block (#47); bulk-accept over the
|
||||||
|
current document (#46). Each remains its own implementation increment / PR.
|
||||||
|
|
||||||
|
**Out of scope / non-goals:**
|
||||||
|
|
||||||
|
- **Selection edits are unchanged** — a selection edit is already a single
|
||||||
|
proposal; #47 touches only the document path, #42 leaves selection behavior
|
||||||
|
intact, #46 reuses the same seam.
|
||||||
|
- **No Explorer context-menu entry** for Ask-Claude (#42 is tab + body only, per
|
||||||
|
the capture; the review-panel Explorer entry shipped separately as #41).
|
||||||
|
- **No "reject all"** (#46 is accept-only; a sibling ask if wanted).
|
||||||
|
- **No cross-document / workspace-wide accept-all** — current document only.
|
||||||
|
- **No new proposal-granularity *setting*** — block is the chosen unit, not a
|
||||||
|
toggle.
|
||||||
|
- **No change to the inline word-level `<ins>`/`<del>` rendering** inside a block
|
||||||
|
— the reviewer still sees exactly what changed; only the *decision unit* and the
|
||||||
|
*attribution reconciliation* change.
|
||||||
|
|
||||||
|
### 1.8 Assumptions · Constraints · Dependencies
|
||||||
|
|
||||||
|
- **F11 already shipped the document-edit path** (`editDocument`,
|
||||||
|
`askClaude`, `runEditAndPropose`, `diffToHunks`). This is the strongest
|
||||||
|
constraint: #42 is mostly **menu wiring** (the command exists); #47 **replaces
|
||||||
|
the diff inside `runEditAndPropose`'s document branch**; #46 **loops the existing
|
||||||
|
`acceptById`**. The cluster adds almost no new surface.
|
||||||
|
- **The block model already exists** — `splitBlocks` / `splitBlocksWithRanges` /
|
||||||
|
`diffBlocks` (`BlockOp`: unchanged / added / removed / changed+atomic; fences
|
||||||
|
atomic per INV-23). #47 **reuses this**, not a second notion of "block."
|
||||||
|
- **The F4 accept seam is the only mutation path** —
|
||||||
|
`ProposalController.accept` → `AttributionController.applyAgentEdit`
|
||||||
|
(`WorkspaceEdit`); the sealed webview posts **intent** only (INV-8/21/35).
|
||||||
|
- **No new persisted artifact** (INV-20) — proposals live in the F4 sidecar; this
|
||||||
|
cluster adds at most one transient field on the proposal (granularity marker —
|
||||||
|
§6.3) and transient webview messages.
|
||||||
|
- VS Code context menus render a command's own `title`; the tab menu picks one of
|
||||||
|
two commands by a `when` clause on selection state (no per-menu relabel of one
|
||||||
|
command).
|
||||||
|
|
||||||
|
### 1.9 Business Use Cases
|
||||||
|
|
||||||
|
- **BUC-1 (reach)** — A writer right-clicks the document (body, no selection) or
|
||||||
|
the tab and asks Claude to edit the whole document.
|
||||||
|
- **BUC-2 (review)** — The writer reads Claude's pass as block-sized proposals
|
||||||
|
and accepts/rejects each.
|
||||||
|
- **BUC-3 (accept)** — The writer, happy with the pass, accepts all pending
|
||||||
|
proposals in one gesture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Solution Proposal
|
||||||
|
|
||||||
|
**One flow, three increments, all on existing seams.**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph reach["#42 — REACH"]
|
||||||
|
body["right-click body / tab\n(selection-aware)"] --> cmd["editSelection /\neditDocument"]
|
||||||
|
end
|
||||||
|
subgraph review["#47 — REVIEW"]
|
||||||
|
cmd --> rep["runEditAndPropose\n(document branch)"]
|
||||||
|
rep -- "diffToBlockHunks\n(per changed block)" --> props["F4 proposals\n(one per block)"]
|
||||||
|
end
|
||||||
|
subgraph accept["#46 — ACCEPT"]
|
||||||
|
props --> one["✓/✗ per block"]
|
||||||
|
props --> all["Accept all\n(toolbar intent)"]
|
||||||
|
end
|
||||||
|
one --> seam["F4 acceptById →\napplyAgentEdit"]
|
||||||
|
all --> seam
|
||||||
|
seam -- "block proposals: word-precise\nintra-block attribution (INV-40)" --> doc[(document)]
|
||||||
|
```
|
||||||
|
|
||||||
|
The defining idea of the cluster is in **#47's accept**: a document edit's
|
||||||
|
proposals are cut at **block** granularity (the unit a human reviews), but each
|
||||||
|
accept reconciles attribution at **word** granularity (the unit F3 records). The
|
||||||
|
block is the **decision** unit; the word is the **attribution** unit. This is what
|
||||||
|
lets us coarsen the review without coarsening the attribution — and it reuses the
|
||||||
|
*exact* word-level machinery (`diffToHunks`) that INV-37 used, now repurposed as
|
||||||
|
the intra-block sub-diff at accept time rather than the proposal cut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Product Personas
|
||||||
|
|
||||||
|
- **PP-1 (the coauthoring writer)** — edits a markdown document with Claude;
|
||||||
|
wants to invoke a whole-document pass quickly, review it at paragraph altitude,
|
||||||
|
and take it wholesale or selectively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Product Use Cases
|
||||||
|
|
||||||
|
| PUC | As PP-1 I… | Feature | Acceptance |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| PUC-1 | right-click the body with **no selection** → **Ask Claude to Edit Document** | #42 | whole-document edit → block proposals in the preview |
|
||||||
|
| PUC-2 | right-click the body **with a selection** → **Ask Claude to Edit Selection** (unchanged) | #42 | one proposal over the selection (existing behavior) |
|
||||||
|
| PUC-3 | right-click the **tab** → Edit Selection (if selection) / Edit Document (if not) | #42 | same behavior as the body equivalents, against that doc |
|
||||||
|
| PUC-4 | see Claude's document edit as **one proposal per changed block** | #47 | M changed blocks → M proposals; unchanged blocks → none; fences atomic |
|
||||||
|
| PUC-5 | accept a block proposal and have **only Claude's actual changes** attributed to Claude | #47 | unchanged words inside the accepted block keep their prior author (INV-40) |
|
||||||
|
| PUC-6 | press **Accept all** to take every pending proposal at once | #46 | all anchored proposals applied; orphans skipped + reported |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UX Layout
|
||||||
|
|
||||||
|
- **#42 entry points.** Two existing commands, surfaced in two more menus:
|
||||||
|
- **Editor body** (`editor/context`): **Ask Claude to Edit Selection** when
|
||||||
|
`editorHasSelection`, **Ask Claude to Edit Document** when not.
|
||||||
|
- **Editor tab** (`editor/title/context`): the same pair, gated the same way,
|
||||||
|
targeting the tab's document.
|
||||||
|
- Both entries appear **only for markdown / authorable** docs (mirroring
|
||||||
|
`editSelection`'s `when`: `resourceScheme == file || untitled` +
|
||||||
|
`editorLangId == markdown`).
|
||||||
|
- **#47 review.** The preview is unchanged in *look* — the reviewer still sees
|
||||||
|
word-level `<ins>`/`<del>` inside each block. What changes: each **changed
|
||||||
|
block** is **one `cw-proposal` block** carrying one `data-proposal-id` and one
|
||||||
|
✓/✗ pair, instead of several. A changed code/mermaid fence is one atomic
|
||||||
|
proposal (INV-23).
|
||||||
|
- **#46 accept-all.** An **Accept all** button in the F11 preview **toolbar**,
|
||||||
|
shown when the document has **≥ 2 pending proposals**. Pressing it posts the
|
||||||
|
`acceptAll` intent; on completion the host reports applied-vs-skipped (status
|
||||||
|
message / toast). No confirmation dialog — VS Code undo restores (consistent
|
||||||
|
with single accept).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Review: my-doc.md ──────────────────────────────┐
|
||||||
|
│ [ Pin baseline ] [ Ask Claude ▾ ] [ Accept all ]│ ← Accept all visible only when ≥2 pending
|
||||||
|
├───────────────────────────────────────────────────┤
|
||||||
|
│ ## Heading │
|
||||||
|
│ ┌───────────────────────────── proposal ───────┐ │
|
||||||
|
│ │ The ~~quick brown~~ swift fox … ✓ ✗ │ │ ← one block, word-level ins/del inside
|
||||||
|
│ └───────────────────────────────────────────────┘ │
|
||||||
|
└───────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Technical Design
|
||||||
|
|
||||||
|
### 6.1 Invariants
|
||||||
|
|
||||||
|
Parent invariants **INV-1..INV-37 carry over except where #47 supersedes
|
||||||
|
INV-37**. This cluster adds:
|
||||||
|
|
||||||
|
- **INV-38 (consistent, selection-aware Ask-Claude entry points)** — "Ask Claude
|
||||||
|
to Edit" is reachable from the editor **body** and the editor **tab**,
|
||||||
|
**selection-scoped when there is a selection** (`cowriting.editSelection`),
|
||||||
|
**whole-document when there is not** (`cowriting.editDocument`). Both menus are
|
||||||
|
gated to **markdown / authorable** docs. Both scopes route through the **single**
|
||||||
|
`runEditAndPropose` path — no divergent edit code (#42).
|
||||||
|
- **INV-39 (document edits propose per changed block — supersedes INV-37)** — a
|
||||||
|
whole-document rewrite is cut into **one F4 proposal per changed block**, where a
|
||||||
|
block is the unit `splitBlocks` already recognizes (prose paragraph / header /
|
||||||
|
bullet; code & mermaid fences **atomic**, INV-23). **Unchanged blocks produce no
|
||||||
|
proposal.** A block proposal's anchor spans the **whole** source block and its
|
||||||
|
replacement is Claude's **whole** version of that block. This **replaces**
|
||||||
|
INV-37's per-hunk word-level granularity **for document edits** (selection edits
|
||||||
|
are unaffected) (#47).
|
||||||
|
- **INV-40 (block decision, word-precise attribution)** — accepting a block
|
||||||
|
proposal applies Claude's whole block but attributes **only the runs Claude
|
||||||
|
actually changed** to Claude; **unchanged spans within the block keep their
|
||||||
|
prior authorship**. Mechanism: an intra-block **word** sub-diff at accept time
|
||||||
|
(reusing `diffToHunks`) drives one `applyAgentEdit` per changed run. The **block
|
||||||
|
is the decision unit; the word is the attribution unit** (#47).
|
||||||
|
- **INV-41 (block-insertion anchoring)** — a newly **inserted** block (one Claude
|
||||||
|
added) is anchored to an adjacent block boundary so its proposal **resolves and
|
||||||
|
is acceptable** — the block-level analogue of `anchorInsertion`; **no
|
||||||
|
unresolvable zero-width block proposal** (#47).
|
||||||
|
- **INV-42 (accept-all = batched F4 seam; current doc; orphan-skip)** —
|
||||||
|
`cowriting.acceptAllProposals` applies **every pending proposal on the current
|
||||||
|
document** through the existing `acceptById` seam, in a **re-anchor-safe order**,
|
||||||
|
**skipping (never force-applying)** proposals that can't anchor, and **reports
|
||||||
|
applied-vs-skipped**. It is a **batched application of the existing accept path**,
|
||||||
|
not a new mechanism; the webview button posts **intent** (INV-35). Per-proposal
|
||||||
|
✓/✗ is unchanged (#46).
|
||||||
|
|
||||||
|
**Supersession (explicit):** **INV-39 reverses INV-37** (document rewrite → per
|
||||||
|
*hunk*/word) in favour of per *block*. INV-37's word-level engine (`diffToHunks`)
|
||||||
|
is **not removed** — it is **repurposed** as the intra-block sub-diff that
|
||||||
|
realizes INV-40's word-precise attribution. The F11 spec carrying INV-37 is
|
||||||
|
un-graduated (§9); this spec is the canonical home of the superseding decision.
|
||||||
|
|
||||||
|
### 6.2 High-level architecture
|
||||||
|
|
||||||
|
All three increments are localized to four already-existing files; no new module
|
||||||
|
is required (one new pure function + one new command + menu JSON).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
pkg["package.json\n(menus: editor/context + editor/title/context)"] -. "#42 wiring" .-> cmds
|
||||||
|
cmds["editSelection / editDocument\n(existing commands)"] --> rep["runEditAndPropose\n(trackChangesPreview.ts)"]
|
||||||
|
rep -- "selection branch (unchanged)" --> single["one single-range proposal"]
|
||||||
|
rep -- "document branch (#47)" --> dbh["diffToBlockHunks\n(trackChangesModel.ts — NEW, pure)"]
|
||||||
|
dbh --> blockprops["one proposal per changed block\n(granularity: 'block')"]
|
||||||
|
single & blockprops --> pc["ProposalController\n(proposalController.ts)"]
|
||||||
|
pc -- "accept (block) → intra-block sub-diff (#47, INV-40)" --> attr["AttributionController.applyAgentEdit\n(per changed run)"]
|
||||||
|
pc -- "acceptAll (#46, INV-42)" --> pc
|
||||||
|
tb["preview toolbar\nAccept all button"] -- "{type:'acceptAll'} intent (INV-35)" --> pc
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`package.json`** (#42) — add `editSelection` + `editDocument` to
|
||||||
|
`editor/context` and `editor/title/context`, each with a selection-state `when`
|
||||||
|
clause; titles "Ask Claude to Edit Selection" / "Ask Claude to Edit Document".
|
||||||
|
- **`trackChangesModel.ts`** (#47, pure / vscode-free) — add
|
||||||
|
**`diffToBlockHunks(currentText, rewrittenText): EditHunk[]`**: split both sides
|
||||||
|
into the existing block units, diff blocks (reusing `diffBlocks`/`diffArrays`
|
||||||
|
keying), and emit **one `EditHunk` per changed-or-added block** spanning the
|
||||||
|
whole block's source range → its rewrite text; fences atomic; inserted blocks
|
||||||
|
anchored (INV-41). Returns the same `EditHunk[]` shape `diffToHunks` does, so the
|
||||||
|
proposal-minting loop is unchanged. **`diffToHunks` stays** as the intra-block
|
||||||
|
sub-diff helper for INV-40.
|
||||||
|
- **`trackChangesPreview.ts`** (#42/#47) — `runEditAndPropose`'s **document
|
||||||
|
branch** calls `diffToBlockHunks` instead of `diffToHunks`, and tags each minted
|
||||||
|
proposal `granularity: "block"`; `editDocument` resolves the **tab's** document
|
||||||
|
(URI arg) when invoked from `editor/title/context`, not only `activeTextEditor`.
|
||||||
|
Adds the `acceptAll` inbound-message case (#46) → `proposals.acceptAllProposals`.
|
||||||
|
- **`proposalController.ts`** (#47/#46):
|
||||||
|
- `accept` (#47): when `proposal.granularity === "block"`, instead of one
|
||||||
|
whole-block `applyAgentEdit`, compute the intra-block word sub-diff
|
||||||
|
(`diffToHunks(resolvedBlockText, proposal.replacement)`) and apply **one
|
||||||
|
`applyAgentEdit` per changed run** (Claude-attributed), unchanged runs left in
|
||||||
|
place — one undo-grouped edit. Non-block proposals keep the existing single
|
||||||
|
`applyAgentEdit`.
|
||||||
|
- `acceptAllProposals(document)` (#46): gather pending proposals, apply via
|
||||||
|
`acceptById` in **descending document order** (last anchor first, so earlier
|
||||||
|
anchors' offsets stay valid), skip orphans (suppressing per-item warnings),
|
||||||
|
and return `{ applied, skipped }` for the controller to report.
|
||||||
|
|
||||||
|
### 6.3 Data model & ownership
|
||||||
|
|
||||||
|
**No new persisted artifact** (INV-20). The only model change is **one optional
|
||||||
|
field** on the F4 `Proposal`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// F4 sidecar Proposal — additive, optional, defaulted (back-compat):
|
||||||
|
interface Proposal {
|
||||||
|
// …existing fields…
|
||||||
|
granularity?: "block" | "single"; // "block" ⇒ accept reconciles attribution per word (INV-40)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- A document-edit proposal is minted with `granularity: "block"`; selection
|
||||||
|
proposals are `"single"` (or absent → treated as single). A missing field on an
|
||||||
|
older sidecar reads as `"single"` — existing proposals accept exactly as before.
|
||||||
|
- New transient webview message (host ⇄ webview), extending the F11 toolbar
|
||||||
|
intents:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// webview → host (intent only — INV-35)
|
||||||
|
type ToolbarMsg =
|
||||||
|
| /* …existing: setMode | accept | reject | pinBaseline | askClaude… */
|
||||||
|
| { type: "acceptAll" };
|
||||||
|
```
|
||||||
|
|
||||||
|
Baseline owned by F6; proposals by F4 (sidecar); attribution by F3. This cluster
|
||||||
|
adds no new ownership.
|
||||||
|
|
||||||
|
### 6.4 Interfaces & contracts
|
||||||
|
|
||||||
|
- **`trackChangesModel`** (vscode-free): `diffToBlockHunks(currentText: string,
|
||||||
|
rewrittenText: string): EditHunk[]` — deterministic; one hunk per changed/added
|
||||||
|
block over the existing block units; fences atomic; inserted blocks anchored
|
||||||
|
(INV-41). Same `EditHunk` shape as `diffToHunks`. `diffToHunks` is **retained**
|
||||||
|
(intra-block sub-diff for INV-40).
|
||||||
|
- **`ProposalController`**: `acceptAllProposals(document): Promise<{ applied:
|
||||||
|
number; skipped: number }>` — batched accept over the document's pending set via
|
||||||
|
the existing `acceptById` seam, re-anchor-safe order, orphan-skip (INV-42). The
|
||||||
|
private `accept` gains the block-aware attribution branch (INV-40) keyed off
|
||||||
|
`proposal.granularity`.
|
||||||
|
- **Commands**: `cowriting.editDocument` (existing — extended to resolve a tab
|
||||||
|
URI arg); new `cowriting.acceptAllProposals` (active doc; reachable outside the
|
||||||
|
webview).
|
||||||
|
|
||||||
|
### 6.5 Per–Product-Use-Case design
|
||||||
|
|
||||||
|
- **PUC-1/2/3 (#42)** — pure `package.json` menu wiring + the `editDocument` tab
|
||||||
|
URI resolution. `runEditAndPropose` is reached unchanged; the only code touch is
|
||||||
|
resolving the right document for the tab gesture (mirroring #41's clicked-doc
|
||||||
|
resolution).
|
||||||
|
- **PUC-4 (#47 cut)** — `diffToBlockHunks` replaces `diffToHunks` in the document
|
||||||
|
branch. For each `BlockOp` that is `changed` or `added`, emit one `EditHunk`:
|
||||||
|
`changed` → `[block.start, block.end) → after-block text`; `added` → a zero-width
|
||||||
|
range anchored to the adjacent block boundary (INV-41) with the new block text +
|
||||||
|
a separating newline as needed; `removed` → a hunk that deletes the block;
|
||||||
|
`unchanged` → nothing. Fences (`atomic`) emit one whole-fence hunk (never
|
||||||
|
word-refined).
|
||||||
|
- **PUC-5 (#47 attribution, INV-40)** — at accept of a `block` proposal: re-resolve
|
||||||
|
the anchor → the current block text; `diffToHunks(currentBlockText,
|
||||||
|
replacement)` → the word-level changed runs *within* the block; apply each run's
|
||||||
|
replacement via `applyAgentEdit` (Claude-attributed) in one undo-grouped
|
||||||
|
`WorkspaceEdit`, leaving unchanged runs untouched so their prior attribution
|
||||||
|
stands. Net buffer text equals the whole-block replacement; net attribution is
|
||||||
|
word-precise.
|
||||||
|
- **PUC-6 (#46)** — `acceptAllProposals`: snapshot the pending list, sort
|
||||||
|
descending by resolved anchor start, `acceptById` each (block proposals take the
|
||||||
|
INV-40 path automatically), tally applied vs. skipped-orphan, report once.
|
||||||
|
|
||||||
|
### 6.6 Non-functional requirements & cross-cutting concerns
|
||||||
|
|
||||||
|
- **Determinism / testability** — `diffToBlockHunks` is pure and vscode-free
|
||||||
|
(unit-tested like `diffToHunks`/`diffBlocks`). The INV-40 attribution
|
||||||
|
reconciliation is exercised through the controller with a fake document.
|
||||||
|
- **Security** — unchanged: the webview stays sealed; **Accept all** posts intent;
|
||||||
|
the host validates and performs every mutation (INV-8/21/35). No LLM/secret
|
||||||
|
surface enters the sandbox.
|
||||||
|
- **Performance** — block diffing is cheaper than the prior word fan-out (fewer
|
||||||
|
proposals minted/rendered). Accept-all is O(pending) seam calls; large documents
|
||||||
|
bounded by proposal count, not document size.
|
||||||
|
- **Reversibility** — accept-all is one undo-restorable step-set; block accept is
|
||||||
|
one undo-grouped edit. No destructive irreversibility.
|
||||||
|
|
||||||
|
### 6.7 Key decisions & alternatives considered
|
||||||
|
|
||||||
|
| Decision | Chosen | Alternative (rejected) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Cluster artifact | **One combined Solution Design**, three increments sequenced #42→#47→#46 (§3.3 — one design absorbs the roadmap) | Three separate specs — more duplication of the shared F11 edit path + F4 seam |
|
||||||
|
| #47 block model | **Reuse `splitBlocks`/`diffBlocks`** | Invent a second "block" notion — divergence risk (capture warned against it) |
|
||||||
|
| #47 attribution of unchanged words in an accepted block | **Preserve unchanged spans** — block decision, word-precise attribution (INV-40), via intra-block `diffToHunks` sub-diff at accept | **Whole block → Claude** — simpler (one seam call) but loses F3 fidelity inside the block; operator chose fidelity |
|
||||||
|
| #47 vs INV-37 | **Supersede** INV-37 for document edits; **repurpose** `diffToHunks` as the intra-block engine | Keep word-level proposals + a separate "group view" — two models, more surface |
|
||||||
|
| #46 apply-order | **Descending document order** through `acceptById` (each accept re-resolves the fingerprint, INV-11) | Re-resolve-then-rescan after every accept — more work for the same result |
|
||||||
|
| #46 confirmation | **None** (undo restores) | A confirm dialog — friction the single-accept path doesn't have |
|
||||||
|
| #46 scope | **Current document only** | Workspace-wide — capture non-goal |
|
||||||
|
| #42 surfaces | **Body + tab only**, selection-aware | Add Explorer entry — capture non-goal (#41 covers Explorer for the review panel) |
|
||||||
|
|
||||||
|
### 6.8 Testing strategy
|
||||||
|
|
||||||
|
- **Unit (vitest, pure)** — `diffToBlockHunks`: N words across M blocks → **M**
|
||||||
|
hunks (one per changed block, spanning the block); unchanged block → none;
|
||||||
|
changed fence → **one atomic** hunk; inserted block → an anchored, resolvable
|
||||||
|
hunk (INV-41); each hunk's `[start,end)` lands on real block boundaries in
|
||||||
|
`currentText`.
|
||||||
|
- **Unit (controller)** — INV-40: accepting a block proposal whose replacement
|
||||||
|
changes 2 of 6 words attributes **only those 2 runs** to Claude (the other 4
|
||||||
|
keep prior author); orphaned block proposal is flagged, not force-applied.
|
||||||
|
- **Host E2E (Playwright)** —
|
||||||
|
- #42: body no-selection shows **Edit Document**; body selection shows **Edit
|
||||||
|
Selection**; tab shows the right one per selection state; each produces
|
||||||
|
proposal(s).
|
||||||
|
- #47: a document edit changing words across M blocks yields **M proposals**,
|
||||||
|
each spanning its block; unchanged block → none; changed fence → one atomic
|
||||||
|
proposal; an inserted block accepts cleanly.
|
||||||
|
- #46: with N pending proposals, **Accept all** applies all N (text replaced,
|
||||||
|
Claude-attributed, proposals cleared); an orphaned proposal in the set is
|
||||||
|
skipped and **reported**, not mangled; the button is hidden with < 2 pending.
|
||||||
|
- **Manual smoke** — extend `docs/MANUAL-SMOKE-F11.md` (or a new
|
||||||
|
`MANUAL-SMOKE-F12.md`) covering the three gestures end-to-end.
|
||||||
|
|
||||||
|
### 6.9 Failure modes, rollback & flags
|
||||||
|
|
||||||
|
- **Orphaned proposal** (target text changed / missing) — single accept already
|
||||||
|
warns + flags (never applied by guess); accept-all **skips** it and includes it
|
||||||
|
in the applied-vs-skipped report (INV-42). Reject-or-undo remains the recovery.
|
||||||
|
- **Inserted block fails to anchor** — INV-41's adjacent-boundary anchoring makes
|
||||||
|
this resolvable; if the boundary block itself later changes, the proposal orphans
|
||||||
|
and is handled as above (never silently dropped).
|
||||||
|
- **Back-compat** — the additive `granularity` field defaults to `"single"`;
|
||||||
|
pre-existing sidecars/proposals accept unchanged. No migration.
|
||||||
|
- **No feature flags** — each increment ships green through the pipeline; this is
|
||||||
|
a VS Code extension (no flotilla deploy stage).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Delivery Plan
|
||||||
|
|
||||||
|
### 7.1 Approach / strategy
|
||||||
|
|
||||||
|
Ship the flow **reach → review → accept** as three independent
|
||||||
|
planning-and-executing increments, each its own PR, in sequence. They are
|
||||||
|
**code-independent** (different files / functions), so the order is chosen for
|
||||||
|
**user-visible value compounding**, not technical dependency — each increment is
|
||||||
|
usable on its own.
|
||||||
|
|
||||||
|
> **WSJF note (transparent):** **#47 carries the P1 value** ("too much to review"
|
||||||
|
> is the sharpest pain); #42 and #46 are P2. The operator set the goal "starting
|
||||||
|
> with #42," and #42 is the smallest, lowest-risk increment (the command already
|
||||||
|
> exists — it's menu wiring), so leading with it completes the entry-point story
|
||||||
|
> first. Because the three are code-independent, **#47 and #42 could swap** with no
|
||||||
|
> rework if the P1 review pain should land first — a one-line resequencing.
|
||||||
|
|
||||||
|
### 7.2 Slicing plan
|
||||||
|
|
||||||
|
- **SLICE-1 — #42 (reach).** `package.json` menus (`editor/context` +
|
||||||
|
`editor/title/context`, selection-aware, markdown-gated); `editDocument`
|
||||||
|
resolves the tab's document; titles. **+ E2E** (body/tab × selection state →
|
||||||
|
right item → proposal). Smallest; no model change. (INV-38.)
|
||||||
|
- **SLICE-2 — #47 (review).** `diffToBlockHunks` (pure, + unit tests); document
|
||||||
|
branch of `runEditAndPropose` switches to it and tags `granularity:"block"`;
|
||||||
|
block-aware accept in `ProposalController` (intra-block sub-diff, INV-40);
|
||||||
|
block-insertion anchoring (INV-41). **Supersede INV-37 → INV-39** in the spec/
|
||||||
|
invariant set. **+ unit + E2E.** The substantive increment. (INV-39/40/41.)
|
||||||
|
- **SLICE-3 — #46 (accept).** `acceptAllProposals` (controller, re-anchor-safe
|
||||||
|
order, orphan-skip + report); `cowriting.acceptAllProposals` command; preview
|
||||||
|
toolbar **Accept all** button + `{type:"acceptAll"}` intent (shown ≥2 pending).
|
||||||
|
**+ E2E.** (INV-42.)
|
||||||
|
|
||||||
|
### 7.3 Rollout / launch plan
|
||||||
|
|
||||||
|
No deploy pipeline (VS Code extension — no flotilla / PPE / prod stages; the §9
|
||||||
|
pipeline's deploy stages don't apply). Each slice merges to `main` green
|
||||||
|
(unit + host E2E). "Done" = merged with tests green; there is no prod-promotion
|
||||||
|
gate for an extension increment.
|
||||||
|
|
||||||
|
### 7.4 Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
| --- | --- |
|
||||||
|
| INV-40 intra-block multi-edit accept skews offsets mid-apply | Apply changed runs **last-position-first** within the block, one undo-grouped `WorkspaceEdit`; unit-test the 2-of-6-words case |
|
||||||
|
| Block taxonomy edge cases (lists w/o blank lines, tables, blockquotes, nested) | **Reuse `splitBlocks` exactly** — whatever it already treats as one block *is* one proposal; document the taxonomy = the splitter's, no second notion |
|
||||||
|
| Inserted-block anchor collides / can't resolve | INV-41 anchors to an adjacent **unchanged** block boundary; orphan-handling is the backstop (never force-applied) |
|
||||||
|
| Accept-all on a large pending set partially applies then an orphan appears | Each `acceptById` is independent + re-resolves (INV-11); skipped orphans reported; undo restores the whole step-set |
|
||||||
|
| F11 spec un-graduated → INV-35/36/37 only in code | This spec restates the load-bearing F11 invariants it depends on; §9 logs the graduation gap as a follow-up |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Traceability matrix
|
||||||
|
|
||||||
|
| Requirement | PUC | Invariant / §6 | Slice | Feature |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Ask-Claude reachable body (no-sel) + tab, selection-aware | PUC-1/2/3 | INV-38, §6.5 | SLICE-1 | #42 |
|
||||||
|
| Selection edit unchanged | PUC-2 | INV-38 (single path) | SLICE-1 | #42 |
|
||||||
|
| Document edit → one proposal per changed block | PUC-4 | INV-39, §6.5 (`diffToBlockHunks`) | SLICE-2 | #47 |
|
||||||
|
| Unchanged block → no proposal; fence atomic | PUC-4 | INV-39, INV-23 | SLICE-2 | #47 |
|
||||||
|
| Accepted block attributes only changed words to Claude | PUC-5 | INV-40, §6.5 | SLICE-2 | #47 |
|
||||||
|
| Inserted block anchors + accepts | PUC-4 | INV-41 | SLICE-2 | #47 |
|
||||||
|
| Accept-all over current doc, orphan-skip + report | PUC-6 | INV-42, §6.5 | SLICE-3 | #46 |
|
||||||
|
| All mutation via the F4 seam; webview intent-only | PUC-4/5/6 | INV-35, §6.6 | SLICE-2/3 | #47/#46 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Open Questions & Decisions log
|
||||||
|
|
||||||
|
**Decided this session (operator):**
|
||||||
|
|
||||||
|
- **D-1** — #47 accepts **preserve unchanged-span attribution** (block = decision
|
||||||
|
unit, word = attribution unit; INV-40). *Not* whole-block→Claude.
|
||||||
|
- **D-2** — the cluster ships as **one** combined Solution Design (this doc),
|
||||||
|
three sequenced increments.
|
||||||
|
- **D-3 (driver call, low-confidence — for finalize review)** — sequence **#42 →
|
||||||
|
#47 → #46** per the goal's "starting with #42," despite #47 carrying the P1
|
||||||
|
value. The three are code-independent so the order is reversible (§7.1).
|
||||||
|
- **D-4 (driver call)** — block taxonomy = **exactly `splitBlocks`'s** units (no
|
||||||
|
second notion of "block"); list/table/blockquote/nested behavior is whatever the
|
||||||
|
splitter already does.
|
||||||
|
- **D-5 (driver call)** — accept-all needs **no confirmation** (undo restores);
|
||||||
|
button shown at **≥ 2** pending.
|
||||||
|
|
||||||
|
**Open / deferred:**
|
||||||
|
|
||||||
|
- **OQ-1** — Should **selection** edits *also* preserve-unchanged-span
|
||||||
|
attribution for consistency with INV-40? Out of #47's captured scope (selection
|
||||||
|
edits explicitly unchanged); a future consistency pass could unify them. **Not
|
||||||
|
in this cluster.**
|
||||||
|
- **OQ-2 (spec-hygiene, not blocking)** — the **F11 (#43) Solution Design is
|
||||||
|
un-graduated** — INV-35/36/37 live only in code comments + the issue draft
|
||||||
|
`issues/preview-toolbar-interaction-surface.md`, not in `specs/`. This spec
|
||||||
|
restates the F11 invariants it depends on and supersedes INV-37 here, but the
|
||||||
|
F11 design should be graduated to `specs/` for a clean invariant ledger.
|
||||||
|
Captured for finalize as a follow-up.
|
||||||
|
- **OQ-3** — Very large blocks: no special fallback is specified (block proposals
|
||||||
|
are bounded by the block, and accept reconciles per word). Revisit only if a
|
||||||
|
pathological single-giant-block document surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Glossary & References
|
||||||
|
|
||||||
|
- **Block** — the unit `splitBlocks` recognizes: a prose paragraph, header, or
|
||||||
|
bullet/list item; a code or mermaid fence (atomic, INV-23). The #47 decision
|
||||||
|
unit.
|
||||||
|
- **Block proposal** — an F4 proposal whose anchor spans a whole block and whose
|
||||||
|
replacement is Claude's whole version of that block (`granularity: "block"`).
|
||||||
|
- **Intra-block sub-diff** — the word-level `diffToHunks` run *inside* an accepted
|
||||||
|
block to realize word-precise attribution (INV-40).
|
||||||
|
- **`diffToHunks` / `diffToBlockHunks`** — word-level (legacy INV-37; now the
|
||||||
|
intra-block engine) / block-level (INV-39, new) document-rewrite differs, both
|
||||||
|
emitting `EditHunk[]`.
|
||||||
|
- **References** — `coauthoring-propose-accept.md` (F4 seam, INV-9..13),
|
||||||
|
`coauthoring-interactive-review.md` (F10 preview, INV-32..34),
|
||||||
|
`coauthoring-attribution.md` (F3, `applyAgentEdit`/`spansFor`),
|
||||||
|
`issues/preview-toolbar-interaction-surface.md` (F11, INV-35..37 — un-graduated),
|
||||||
|
features `#42` / `#47` / `#46`, capture sessions `0035` / `0040` / `0039`,
|
||||||
|
brainstorming session `0041`. Lineage `ben.stull/rfc-app#48`.
|
||||||
Reference in New Issue
Block a user