F7 gains an Authorship mode (segmented header toggle) that renders the current doc with each span colored by its F3 author (Claude blue / human green), inline and char-precise via PUA sentinel injection; code/mermaid fences get a block-level author badge. Baseline-independent (INV-26), reads AttributionController.spansFor. INV-26..28. Surfaced as friction during F8 testing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
10 KiB
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:
- 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.
- 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:
- Block split with offsets. Reuse
splitBlocksbut track each block's[start, end)char range incurrentText(add the offsets to theBlockshape, or a parallelsplitBlocksWithRanges). Blank-line gaps between blocks are outside any block range (consistent with today's whitespace handling). - Per block:
- Prose block: clip
spansto 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-replaceU+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/mixedwhen both authors overlap); else render plain.
- Prose block: clip
- Concatenate block HTML (same join as
renderTrackChanges). Each block keeps the existing per-blocktry/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.
AttributionControlleralready maintains live spans as current-buffer char ranges with author kind (getSpans(key)→RenderedSpan[],authorKind: "human" | "agent"). AddspansFor(document): AuthorSpan[]toAttributionController— it computes the document key internally (this.keyOf(document)via the F8 router) and mapsagent→"claude",human→"human". This resolves the key mismatch (the preview keys panels bydocument.uri.toString(); attribution keys bykeyOf). - Preview gains an attribution dependency.
TrackChangesPreviewControllertakesAttributionControllerin its constructor. Inextension.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-headerand asetModemessage; render the posted HTML into#cw-bodyas 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 theproposeAgentEditseam + 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 intrackChangesModel.ts(the sentinel inject/post-process; atomic fences) + unit tests. - SLICE-2
AttributionController.spansFor+ wire it intoTrackChangesPreviewController(constructor dep,extension.tsreorder) + per-panel mode state + thesetModemessage. - 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.