Files
vscode-cowriting-plugin-con…/specs/2026-06-11-authorship-preview-design.md
T

187 lines
10 KiB
Markdown

---
status: graduated
---
# Solution Design: Authorship view in the rendered preview (F9)
| | |
| --- | --- |
| **Author(s)** | Ben Stull (with Claude) |
| **Status** | `draft` |
| **Version** | v0.1.0 |
| **Anchor** | Feature **F9** (to be captured, ~#27) — builds on F7 `#21` (rendered preview) + F3 `#6` (live attribution). Surfaced as friction during F8 (`#25`) testing: "the preview doesn't show the Claude-composed annotations." |
---
## 1. Problem & context
The F7 rendered track-changes preview (`src/trackChangesPreview.ts` +
`src/trackChangesModel.ts`) answers **"what changed since the F6 baseline?"** It
diffs `baselineText` vs the live buffer and renders author-*agnostic*
`added`/`removed`/`changed` marks. It has never read F3 attribution.
Two facts combine into the friction:
1. The F6 baseline **advances on every machine landing** (INV-18): when a Claude
proposal is accepted, its text is folded *into* the baseline, so it then reads
as **unchanged** in track-changes.
2. The preview has **no authorship axis** — it cannot say "Claude composed this
span" vs "you did."
So a writer who accepts Claude's edits cannot *see* what Claude contributed in the
preview. F3 attribution already records exactly that (Claude=`agent` vs human
spans, char-precise), and F8 made attribution available on **any** authorable doc
(in-folder / out-of-folder / untitled) — the data exists; the preview just doesn't
show it.
**Goal:** add an **Authorship** mode to the preview that renders the current
document with each span colored by its F3 author — Claude (blue) vs you (green) —
inline and char-precise, reading attribution directly (baseline-independent), so
Claude's contributions are visible even after the baseline absorbs them.
## 2. Solution overview
The preview gains a **second mode**, switched by a **segmented control in the
webview header** (`[ Track changes | Authorship ]`); mode is remembered per panel,
default **Track changes** (today's behavior, unchanged).
- **Track changes mode** — unchanged (`renderTrackChanges(baselineText, current)`).
- **Authorship mode** — renders the **current buffer** (no baseline diff) via a new
pure engine `renderAuthorship(currentText, authorSpans)`, wrapping each
attributed span in `<span class="cw-by-claude">` / `<span class="cw-by-human">`.
A header **legend** (● Claude / ● You) appears in this mode. Text with no
attribution record renders plain.
The two modes are distinct renderings of different axes; authorship mode does **not**
combine with the baseline diff (no `ins`/`del`, no removed text — there is no diff).
## 3. Technical design
### 3.1 The render engine (pure, vscode-free — extends `trackChangesModel.ts`)
New export `renderAuthorship(currentText: string, spans: AuthorSpan[]): string`,
where `AuthorSpan = { start: number; end: number; author: "claude" | "human" }`
(char offsets into `currentText`, non-overlapping). Deterministic (INV-22 extends
to it): same inputs → identical HTML; no vscode, no DOM.
Algorithm:
1. **Block split with offsets.** Reuse `splitBlocks` but track each block's
`[start, end)` char range in `currentText` (add the offsets to the `Block`
shape, or a parallel `splitBlocksWithRanges`). Blank-line gaps between blocks
are outside any block range (consistent with today's whitespace handling).
2. **Per block:**
- **Prose block:** clip `spans` to the block's range, translate to block-local
offsets, and inject paired **sentinel markers** at the local boundaries into
the block's raw markdown — four Unicode Private-Use Area code points: `U+E000`
(Claude-open) / `U+E001` (Claude-close), `U+E002` (human-open) / `U+E003`
(human-close). PUA code points never appear in real content and markdown-it
passes them through as plain text. Render the block with the existing
markdown-it instance, then **post-process** the HTML: string-replace
`U+E000``<span class="cw-by-claude">`, `U+E001``</span>`,
`U+E002``<span class="cw-by-human">`, `U+E003``</span>`.
- **Code / mermaid fence (atomic, INV-23 extends → INV-27):** never inject
sentinels inside a fence. If any span overlaps the fence, wrap the rendered
fence in a block div carrying the author class + a small badge
(`Claude` / `You` / `mixed` when both authors overlap); else render plain.
3. Concatenate block HTML (same join as `renderTrackChanges`). Each block keeps
the existing per-block `try/catch` → error chip, so a render failure degrades
to a visible chip, never a throw.
**Known edge case (documented):** a sentinel placed immediately adjacent to active
emphasis markers (`**`, `_`) can perturb markdown-it's inline parsing for that
span. Spans from F3 fall on real edit boundaries, so this is rare; v1 accepts it
(the per-block chip prevents any hard failure) and covers the common cases with
tests. Snapping span boundaries to token-safe positions is a deferred refinement.
### 3.2 Data source & wiring
- **Spans come from F3.** `AttributionController` already maintains live spans as
current-buffer char ranges with author kind (`getSpans(key)``RenderedSpan[]`,
`authorKind: "human" | "agent"`). Add `spansFor(document): AuthorSpan[]` to
`AttributionController` — it computes the document key internally
(`this.keyOf(document)` via the F8 router) and maps `agent→"claude"`,
`human→"human"`. This resolves the key mismatch (the preview keys panels by
`document.uri.toString()`; attribution keys by `keyOf`).
- **Preview gains an attribution dependency.** `TrackChangesPreviewController`
takes `AttributionController` in its constructor. In `extension.ts`, construct
the authoring stack (router, guard, attribution) **before** the F7 controller
so the dependency is available (a small, safe reorder — F8 already removed the
no-root early return, so all controllers are constructed unconditionally).
- **Mode state & toggle.** The controller holds `mode: Map<key, "changes"|"authorship">`
(default `"changes"`). `refresh(document)` renders the panel's current mode:
track-changes (existing path) or authorship (`renderAuthorship(current,
attribution.spansFor(document))`), posting `{type:"render", html, mode, epoch?,
summary?, legend?}`. The webview header shows the segmented toggle; clicking
posts `{type:"setMode", mode}` back; the controller updates the map and
re-renders. Edits/baseline-changes re-render in the current mode.
- **Webview (`media/`):** add the segmented toggle to `#cw-header` and a
`setMode` message; render the posted HTML into `#cw-body` as today; show/hide
the epoch+summary (track-changes) vs the legend (authorship) per mode. CSS adds
`.cw-by-claude` (blue) / `.cw-by-human` (green) + the badge/legend styles,
themed via VS Code CSS variables (consistent with the existing preview CSS).
### 3.3 Invariants (additions)
- **INV-26 (authorship mode is baseline-independent):** authorship mode renders
the **current buffer** colored by F3 author, reading `AttributionController`,
never the F6 baseline. Track-changes mode is unchanged. The two are distinct
modes, never combined.
- **INV-27 (fences stay atomic in authorship too):** a code/mermaid fence is never
marked inline; an overlapping author span yields a **block-level** author badge
(extends INV-23).
- **INV-28 (pure authorship render):** `renderAuthorship(currentText, spans)` is
vscode-free and deterministic (extends INV-22) — spans are passed as data, so it
unit-tests with no editor.
- INV-19/20/21 (read-only preview, sealed webview, no persistence/network) carry
over: authorship mode reads attribution, never mutates the document, sidecar, or
baseline; no new LLM/network/credential surface.
## 4. Scope
**In scope:** the Authorship mode + header toggle; `renderAuthorship` engine;
`AttributionController.spansFor`; the preview↔attribution wiring + `extension.ts`
reorder; webview toggle/legend/CSS; unit + host E2E + a manual-smoke addendum.
**Out of scope / non-goals:** combining authorship with the diff in one view
(rejected in design — two modes); marking removed text (no diff in authorship
mode); the "untracked / always-there" third author class (rejected — only Claude &
human are colored, unattributed renders plain); intra-emphasis sentinel-safety
hardening (deferred); any change to F3 attribution capture, the seam, persistence,
or the cross-rung contract.
## 5. Testing strategy
- **Unit (vitest, vscode-free):** `renderAuthorship`
- single Claude span / single human span → correct wrapper class;
- two authors in one prose paragraph → exact inline boundaries;
- span clipped at a block boundary (spans one block only);
- a code fence and a mermaid fence overlapping a span → block-level badge,
no inner sentinels (atomic);
- adjacent same-author spans; a span covering a whole block;
- empty `spans` → plain render (equals markdown-it of the source);
- determinism (same inputs → identical HTML).
- **Host E2E (`@vscode/test-electron`, no LLM, extends the F7 suite):** open the
preview on a markdown doc → land a Claude edit via the `proposeAgentEdit` seam +
accept → set authorship mode → assert the posted model marks Claude's span as
Claude-authored (and a human span as human). Assert track-changes mode still
renders as before (regression).
- **Manual smoke (`docs/MANUAL-SMOKE-F9.md`):** the webview visuals — blue/green
colors, the header toggle, the legend, a mixed-author paragraph, a Claude-authored
mermaid fence badge — verified by eye (the sealed webview's rendering isn't
covered by the E2E).
## 6. Delivery slices
- **SLICE-1** `renderAuthorship` + block-offset split in `trackChangesModel.ts`
(the sentinel inject/post-process; atomic fences) + unit tests.
- **SLICE-2** `AttributionController.spansFor` + wire it into
`TrackChangesPreviewController` (constructor dep, `extension.ts` reorder) +
per-panel mode state + the `setMode` message.
- **SLICE-3** webview header toggle + legend + `.cw-by-*` CSS (`media/`).
- **SLICE-4** host E2E (authorship-mode assertion + track-changes regression) +
`docs/MANUAL-SMOKE-F9.md` + README F9 note.
**Done =** authorship mode shows Claude's spans (blue) and yours (green) inline on
a markdown doc; the header toggle flips modes; code/mermaid fences get a block
badge; track-changes mode unchanged; unit + host E2E green; smoke performed once.