From 8fa83f68c835657fd5d492f8275be178da11ccc6 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 14:04:18 -0700 Subject: [PATCH 1/9] design(f9): authorship view in the rendered preview 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 --- .../2026-06-11-authorship-preview-design.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-11-authorship-preview-design.md diff --git a/docs/superpowers/specs/2026-06-11-authorship-preview-design.md b/docs/superpowers/specs/2026-06-11-authorship-preview-design.md new file mode 100644 index 0000000..4b6385c --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-authorship-preview-design.md @@ -0,0 +1,183 @@ +# 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 `` / ``. + 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`→``, `U+E001`→``, + `U+E002`→``, `U+E003`→``. + - **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` + (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. -- 2.39.5 From 0eaca37d5e1d432bbfbb19474f8660bdf67d024c Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 14:34:04 -0700 Subject: [PATCH 2/9] plan(f9): authorship preview implementation plan (7 TDD tasks) Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-11-f9-authorship-preview.md | 788 ++++++++++++++++++ 1 file changed, 788 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-f9-authorship-preview.md diff --git a/docs/superpowers/plans/2026-06-11-f9-authorship-preview.md b/docs/superpowers/plans/2026-06-11-f9-authorship-preview.md new file mode 100644 index 0000000..dafd4d9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-f9-authorship-preview.md @@ -0,0 +1,788 @@ +# F9 Authorship Preview Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an **Authorship** mode to the F7 rendered preview — a header toggle that re-renders the current markdown doc with each span colored by its F3 author (Claude blue / human green), inline and char-precise — so a writer can see what Claude composed even after the F6 baseline absorbs it. + +**Architecture:** A new pure render engine `renderAuthorship(currentText, authorSpans)` in `src/trackChangesModel.ts` (vscode-free, unit-tested) injects Private-Use-Area sentinels at attribution-span boundaries through markdown-it, then post-processes them into ``; code/mermaid fences stay atomic with a block-level author badge. `AttributionController.spansFor(document)` supplies the spans (resolving the URI-vs-keyOf key mismatch). `TrackChangesPreviewController` gains a per-panel mode and an attribution dependency; the webview header gets a segmented `[ Track changes | Authorship ]` toggle that posts `setMode` back. + +**Tech Stack:** TypeScript, VS Code webview API, `markdown-it`, vitest (unit), `@vscode/test-electron` + mocha (host E2E). Design: `docs/superpowers/specs/2026-06-11-authorship-preview-design.md`. + +**Conventions (read first):** +- The render engine is **vscode-free** and **deterministic** (INV-22/28) — spans are passed as data, tested under vitest like `test/trackChangesModel.test.ts`. +- Avoid `*/` inside block comments (an earlier F8 hiccup: a `**/` glob closed a comment early). Keep glob examples out of JSDoc. +- Attribution offsets are char offsets into `document.getText()` — the same string passed to the renderer as `currentText`, so they align. +- Run unit: `npm test`. Typecheck: `npm run typecheck`. Build: `npm run build`. Host E2E: `npm run test:e2e`. +- After each task: `npm test` green + `npm run typecheck` clean, then commit. + +--- + +## File Structure + +**Modify:** +- `src/trackChangesModel.ts` — add `AuthorKind`/`AuthorSpan`, `splitBlocksWithRanges`, `renderAuthorship`. +- `src/attributionController.ts` — add `spansFor(document): AuthorSpan[]`. +- `src/trackChangesPreview.ts` — attribution dependency; per-panel mode; `setMode` handling; render branch; post `mode`/`legend`. +- `src/extension.ts` — reorder so attribution exists before the F7 controller; pass it in. +- `media/preview.ts` — segmented toggle + `acquireVsCodeApi` postMessage; legend vs summary per mode. +- `media/preview.css` — `.cw-by-claude` / `.cw-by-human`, toggle, legend, fence-badge styles. +- `README.md` — F9 note. + +**Create:** +- `test/e2e/suite/authorship.test.ts` — host E2E. +- `docs/MANUAL-SMOKE-F9.md` — manual smoke runbook. + +**Untouched:** `renderTrackChanges`/`diffBlocks` behavior (track-changes mode), F6 baseline, F3 capture, the seam, persistence. + +--- + +## Task 1: `splitBlocksWithRanges` (block offsets) + +**Files:** +- Modify: `src/trackChangesModel.ts` +- Test: `test/trackChangesModel.test.ts` + +- [ ] **Step 1: Write the failing test (append to `test/trackChangesModel.test.ts`)** + +```typescript +import { splitBlocksWithRanges } from "../src/trackChangesModel"; + +describe("splitBlocksWithRanges — block offsets align with the source string", () => { + it("each block's [start,end) slices back to its raw from the source", () => { + const text = "# Title\n\nA prose paragraph.\n\n```js\ncode()\n```\n"; + const blocks = splitBlocksWithRanges(text); + expect(blocks.map((b) => b.type)).toEqual(["prose", "prose", "code"]); + for (const b of blocks) { + expect(text.slice(b.start, b.end)).toBe(b.raw); + } + expect(blocks[1].raw).toBe("A prose paragraph."); + }); + + it("marks a mermaid fence and preserves its source offsets", () => { + const text = "intro\n\n```mermaid\ngraph TD; A-->B;\n```\n"; + const blocks = splitBlocksWithRanges(text); + expect(blocks[1].type).toBe("mermaid"); + expect(text.slice(blocks[1].start, blocks[1].end)).toBe(blocks[1].raw); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm test -- trackChangesModel` +Expected: FAIL (`splitBlocksWithRanges` is not exported). + +- [ ] **Step 3: Implement `splitBlocksWithRanges` in `src/trackChangesModel.ts`** + +Add after `splitBlocks`: + +```typescript +export interface BlockWithRange extends Block { + /** char offset of the block's first char in the source string. */ + start: number; + /** char offset one past the block's last char (source.slice(start,end) === raw). */ + end: number; +} + +/** + * Like splitBlocks, but each block carries its [start,end) char range in the + * SOURCE string (offsets align with document.getText(), so F3 attribution + * offsets map directly). Lines are tracked with their true source offsets + * (a trailing CR stays in the line, so \r\n sources keep correct offsets). + */ +export function splitBlocksWithRanges(text: string): BlockWithRange[] { + // line raw (excluding the \n terminator) + its source start offset. + const lines: { raw: string; start: number }[] = []; + let from = 0; + for (let i = 0; i <= text.length; i++) { + if (i === text.length || text[i] === "\n") { + lines.push({ raw: text.slice(from, i), start: from }); + from = i + 1; + if (i === text.length) break; + } + } + const out: BlockWithRange[] = []; + const range = (lo: number, hi: number): { start: number; end: number } => { + const start = lines[lo].start; + const end = lines[hi].start + lines[hi].raw.length; + return { start, end }; + }; + let buf: number[] = []; // indices of buffered prose lines + const flushProse = () => { + if (buf.length) { + const { start, end } = range(buf[0], buf[buf.length - 1]); + const raw = text.slice(start, end); + if (raw.trim()) out.push({ ...makeBlockRange(raw, "prose"), start, end }); + } + buf = []; + }; + let i = 0; + while (i < lines.length) { + const line = lines[i].raw; + const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); + if (fence) { + flushProse(); + const marker = fence[2][0]; + const info = fence[3].trim().split(/\s+/)[0].toLowerCase(); + const open = i; + i++; + while (i < lines.length) { + const closed = lines[i].raw.trim().startsWith(marker.repeat(3)); + i++; + if (closed) break; + } + const close = i - 1; + const { start, end } = range(open, close); + const raw = text.slice(start, end); + out.push({ ...makeBlockRange(raw, info === "mermaid" ? "mermaid" : "code"), start, end }); + continue; + } + if (line.trim() === "") { + flushProse(); + i++; + continue; + } + buf.push(i); + i++; + } + flushProse(); + return out; +} + +function makeBlockRange(raw: string, type: BlockType): Block { + return makeBlock(raw, type); +} +``` + +(`makeBlockRange` is a thin alias so the spread `{ ...makeBlockRange(...), start, end }` reads clearly; it reuses the existing `makeBlock`.) + +- [ ] **Step 4: Run to verify it passes** + +Run: `npm test -- trackChangesModel` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/trackChangesModel.ts test/trackChangesModel.test.ts +git commit -m "feat(f9): splitBlocksWithRanges — block offsets aligned to the source" +``` + +--- + +## Task 2: `renderAuthorship` — inline spans + atomic fences + +**Files:** +- Modify: `src/trackChangesModel.ts` +- Test: `test/trackChangesModel.test.ts` + +- [ ] **Step 1: Write the failing tests (append)** + +```typescript +import { renderAuthorship, type AuthorSpan } from "../src/trackChangesModel"; + +const spanAt = (text: string, sub: string, author: "claude" | "human"): AuthorSpan => { + const start = text.indexOf(sub); + return { start, end: start + sub.length, author }; +}; + +describe("renderAuthorship", () => { + it("empty spans → plain render (no author wrappers)", () => { + const text = "# Hi\n\nplain paragraph.\n"; + const html = renderAuthorship(text, []); + expect(html).not.toContain("cw-by-claude"); + expect(html).not.toContain("cw-by-human"); + expect(html).toContain("plain paragraph."); + }); + + it("wraps a single Claude span inline", () => { + const text = "The cat sat on the mat.\n"; + const html = renderAuthorship(text, [spanAt(text, "cat sat", "claude")]); + expect(html).toContain('cat sat'); + }); + + it("marks two authors in one paragraph at exact boundaries", () => { + const text = "Alpha beta gamma.\n"; + const html = renderAuthorship(text, [ + spanAt(text, "Alpha", "human"), + spanAt(text, "gamma", "claude"), + ]); + expect(html).toContain('Alpha'); + expect(html).toContain('gamma'); + }); + + it("clips a span to its block (does not bleed across blocks)", () => { + const text = "Para one.\n\nPara two.\n"; + // a span covering the whole text; each block wraps only its own slice + const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]); + expect(html).toContain('Para one.'); + expect(html).toContain('Para two.'); + }); + + it("a code fence overlapping a span gets a block badge, NOT inner sentinels (atomic)", () => { + const text = "```js\nconst x = 1;\n```\n"; + const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]); + expect(html).toContain("cw-by-claude"); + expect(html).toContain("cw-badge"); + expect(html).not.toContain(""); // no sentinel leaked into the code + expect(html).toContain("const x = 1;"); + }); + + it("a mermaid fence authored by Claude renders as a diagram with a badge", () => { + const text = "```mermaid\ngraph TD; A-->B;\n```\n"; + const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]); + expect(html).toContain('pre class="mermaid"'); + expect(html).toContain("cw-by-claude"); + expect(html).toContain("cw-badge"); + }); + + it("is deterministic", () => { + const text = "Stable input paragraph.\n"; + const spans: AuthorSpan[] = [spanAt(text, "input", "claude")]; + expect(renderAuthorship(text, spans)).toBe(renderAuthorship(text, spans)); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm test -- trackChangesModel` +Expected: FAIL (`renderAuthorship`/`AuthorSpan` not exported). + +- [ ] **Step 3: Implement in `src/trackChangesModel.ts`** + +```typescript +export type AuthorKind = "claude" | "human"; +export interface AuthorSpan { + start: number; + end: number; + author: AuthorKind; +} + +// Private-Use-Area sentinels (never appear in real content; markdown-it passes +// them through as plain text). Paired open/close per author. +const SENT = { + claude: { open: "", close: "" }, + human: { open: "", close: "" }, +} as const; + +function authorBadge(authors: Set): { cls: string; label: string } | null { + if (authors.size === 0) return null; + if (authors.size > 1) return { cls: "cw-mixed", label: "mixed" }; + const only = [...authors][0]; + return only === "claude" + ? { cls: "cw-by-claude", label: "Claude" } + : { cls: "cw-by-human", label: "You" }; +} + +/** 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 { + // Build insertions as (localOffset, marker); apply right-to-left so offsets stay valid. + const inserts: { at: number; marker: string }[] = []; + for (const s of spans) { + const lo = Math.max(0, s.start - blockStart); + const hi = Math.min(raw.length, s.end - blockStart); + if (hi <= lo) continue; + inserts.push({ at: lo, marker: SENT[s.author].open }); + inserts.push({ at: hi, marker: SENT[s.author].close }); + } + // Close markers must come BEFORE open markers at the same offset to keep nesting + // tidy; otherwise sort by offset descending and, at equal offset, close first. + inserts.sort((a, b) => (b.at - a.at) || (isClose(a.marker) ? -1 : 1)); + let out = raw; + for (const ins of inserts) out = out.slice(0, ins.at) + ins.marker + out.slice(ins.at); + return out; +} +function isClose(m: string): boolean { + return m === SENT.claude.close || m === SENT.human.close; +} + +/** Replace the rendered sentinels with author tags. */ +function sentinelsToSpans(html: string): string { + return html + .split(SENT.claude.open).join('') + .split(SENT.claude.close).join("") + .split(SENT.human.open).join('') + .split(SENT.human.close).join(""); +} + +/** + * Pure authorship render (INV-26/28): the CURRENT text with each F3-attributed + * span colored by author. Prose blocks get inline ``; + * code/mermaid fences stay ATOMIC (INV-27) — an overlapping span yields a + * block-level author badge, never inner sentinels. Deterministic. + */ +export function renderAuthorship( + currentText: string, + spans: AuthorSpan[], + opts: RenderOptions = {}, +): string { + const render = opts.render ?? defaultRender; + const safe = (src: string): string => { + try { + return render(src); + } catch (err) { + return chip(err instanceof Error ? err.message : String(err)); + } + }; + return splitBlocksWithRanges(currentText) + .map((b) => { + const overlapping = spans.filter((s) => s.end > b.start && s.start < b.end); + if (b.type !== "prose") { + const badge = authorBadge(new Set(overlapping.map((s) => s.author))); + const inner = safe(b.raw); + if (!badge) return `
${inner}
`; + return `
${badge.label}${inner}
`; + } + const injected = injectSentinels(b.raw, b.start, overlapping); + return `
${sentinelsToSpans(safe(injected))}
`; + }) + .join("\n"); +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npm test -- trackChangesModel` +Expected: PASS. If the inline-span test fails because markdown-it wraps prose in `

`, the assertion still holds (`cat sat` appears inside the `

`). If a sentinel survives in output, check `sentinelsToSpans` ordering. + +- [ ] **Step 5: Commit** + +```bash +git add src/trackChangesModel.ts test/trackChangesModel.test.ts +git commit -m "feat(f9): renderAuthorship — inline author spans + atomic fence badges (INV-26/27/28)" +``` + +--- + +## Task 3: `AttributionController.spansFor` + +**Files:** +- Modify: `src/attributionController.ts` + +- [ ] **Step 1: Add the method** + +In `src/attributionController.ts`, import the type and add a public method near `getSpans`: + +```typescript +import type { AuthorSpan } from "./trackChangesModel"; +``` + +```typescript + /** + * F9: the document's live attribution as authorship spans for the preview — + * current-buffer char ranges mapped to author kind (agent→claude). Computes + * the document key internally (so callers pass a TextDocument, not the key). + */ + spansFor(document: vscode.TextDocument): AuthorSpan[] { + return this.getSpans(this.keyOf(document)).map((s) => ({ + start: s.range.start, + end: s.range.end, + author: s.authorKind === "agent" ? "claude" : "human", + })); + } +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `npm run typecheck` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add src/attributionController.ts +git commit -m "feat(f9): AttributionController.spansFor — authorship spans for the preview" +``` + +--- + +## Task 4: Wire mode + attribution into the preview controller + +**Files:** +- Modify: `src/trackChangesPreview.ts`, `src/extension.ts` + +- [ ] **Step 1: Controller — attribution dep, mode state, render branch, setMode** + +In `src/trackChangesPreview.ts`: + +Imports + fields: +```typescript +import { renderTrackChanges, renderAuthorship, diffBlocks, type BlockOp } from "./trackChangesModel"; +import type { AttributionController } from "./attributionController"; +``` +Add a per-panel mode map field: +```typescript + private readonly mode = new Map(); +``` +Constructor — add the attribution param (after `extensionUri`): +```typescript + constructor( + private readonly diffView: DiffViewController, + private readonly extensionUri: vscode.Uri, + private readonly attribution: AttributionController, + ) { +``` + +In `show(...)`, after creating the panel, wire incoming messages (the toggle). Add to the `panel.onDidDispose` registration area: +```typescript + panel.webview.onDidReceiveMessage( + (m: { type?: string; mode?: "changes" | "authorship" }) => { + if (m?.type === "setMode" && (m.mode === "changes" || m.mode === "authorship")) { + this.mode.set(key, m.mode); + this.refresh(document); + } + }, + null, + this.disposables, + ); +``` + +Rewrite `refresh(document)` to branch on mode: +```typescript + refresh(document: vscode.TextDocument): void { + const key = document.uri.toString(); + const panel = this.panels.get(key); + if (!panel) return; + const mode = this.mode.get(key) ?? "changes"; + const current = document.getText(); + if (mode === "authorship") { + const spans = this.attribution.spansFor(document); + void panel.webview.postMessage({ + type: "render", + mode, + html: renderAuthorship(current, spans), + legend: { claude: spans.some((s) => s.author === "claude"), human: spans.some((s) => s.author === "human") }, + }); + this.lastModel.set(key, diffBlocks(this.diffView.getBaseline(key)?.text ?? current, current)); + return; + } + const baseline = this.diffView.getBaseline(key); + const baselineText = baseline?.text ?? current; + const ops = diffBlocks(baselineText, current); + this.lastModel.set(key, ops); + const summary = { + added: ops.filter((o) => o.kind === "added").length, + removed: ops.filter((o) => o.kind === "removed").length, + changed: ops.filter((o) => o.kind === "changed").length, + }; + void panel.webview.postMessage({ + type: "render", + mode, + html: renderTrackChanges(baselineText, current), + epoch: this.epochLabel(baseline), + summary, + }); + } +``` + +Add a test seam for the current mode (used by E2E): +```typescript + getMode(uriString: string): "changes" | "authorship" { + return this.mode.get(uriString) ?? "changes"; + } + setMode(uriString: string, mode: "changes" | "authorship"): void { + this.mode.set(uriString, mode); + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); + if (doc) this.refresh(doc); + } +``` + +- [ ] **Step 2: extension.ts — reorder + pass attribution** + +In `src/extension.ts`, the F7 controller is currently constructed in the F6/F7 block (before the authoring stack). Move the `TrackChangesPreviewController` construction to AFTER `attributionController` is created, and pass it: + +1. Delete the existing block: +```typescript + const trackChangesPreviewController = new TrackChangesPreviewController( + diffViewController, + context.extensionUri, + ); + context.subscriptions.push(trackChangesPreviewController); +``` +2. Re-create it right after `const attributionController = new AttributionController(...)` + its `context.subscriptions.push(attributionController);`: +```typescript + // --- F7: rendered track-changes preview (Feature #21) + F9 authorship mode --- + // Constructed after attribution so the authorship view can read F3 spans. + const trackChangesPreviewController = new TrackChangesPreviewController( + diffViewController, + context.extensionUri, + attributionController, + ); + context.subscriptions.push(trackChangesPreviewController); +``` +(The `CowritingApi` return already includes `trackChangesPreviewController` — unchanged. The F6 `diffViewController` stays where it is.) + +- [ ] **Step 3: Verify typecheck + unit** + +Run: `npm run typecheck && npm test` +Expected: clean + 150+ tests green (Tasks 1–3 added tests). + +- [ ] **Step 4: Commit** + +```bash +git add src/trackChangesPreview.ts src/extension.ts +git commit -m "feat(f9): preview gains authorship mode + attribution dep + setMode wiring" +``` + +--- + +## Task 5: Webview toggle, legend, and CSS + +**Files:** +- Modify: `src/trackChangesPreview.ts` (shell HTML), `media/preview.ts`, `media/preview.css` + +- [ ] **Step 1: Shell HTML — add the segmented toggle + legend slot** + +In `src/trackChangesPreview.ts` `shellHtml(...)`, replace the `

` with: +```html +
+
+ + +
+ Track changes + + +
+``` + +- [ ] **Step 2: `media/preview.ts` — post setMode; render per mode** + +Replace the message interface + handler and add the toggle wiring: +```typescript +interface RenderMessage { + type: "render"; + mode: "changes" | "authorship"; + html: string; + epoch?: string; + summary?: { added: number; removed: number; changed: number }; + legend?: { claude: boolean; human: boolean }; +} + +const vscodeApi = acquireVsCodeApi(); +const body = document.getElementById("cw-body")!; +const header = document.getElementById("cw-epoch")!; +const summary = document.getElementById("cw-summary")!; +const legend = document.getElementById("cw-legend")!; +const segs = Array.from(document.querySelectorAll(".cw-seg")); + +for (const seg of segs) { + seg.addEventListener("click", () => { + vscodeApi.postMessage({ type: "setMode", mode: seg.dataset.mode }); + }); +} + +window.addEventListener("message", (event: MessageEvent) => { + const msg = event.data; + if (msg?.type !== "render") return; + body.innerHTML = msg.html; + for (const seg of segs) seg.classList.toggle("cw-seg-on", seg.dataset.mode === msg.mode); + const authorship = msg.mode === "authorship"; + header.hidden = authorship; + summary.hidden = authorship; + legend.hidden = !authorship; + if (authorship) { + const parts: string[] = []; + if (msg.legend?.claude) parts.push('Claude'); + if (msg.legend?.human) parts.push('You'); + legend.innerHTML = parts.join(" ") || "no attribution yet"; + } else { + header.textContent = `Track changes since ${msg.epoch ?? ""}`; + summary.innerHTML = + `+${(msg.summary?.added ?? 0) + (msg.summary?.changed ?? 0)} ` + + `−${(msg.summary?.removed ?? 0) + (msg.summary?.changed ?? 0)}`; + } + void renderMermaid(); +}); +``` +(`acquireVsCodeApi` is a webview global; add `declare function acquireVsCodeApi(): { postMessage(m: unknown): void };` near the top of the file if the type isn't already present.) + +- [ ] **Step 3: `media/preview.css` — author + toggle + legend styles** + +Append: +```css +.cw-by-claude { background: var(--vscode-editorInfo-foreground, rgba(64, 120, 242, 0.18)); text-decoration: none; } +.cw-by-human { background: var(--vscode-gitDecoration-addedResourceForeground, rgba(46, 160, 67, 0.18)); text-decoration: none; } +/* Block-level author badges reuse .cw-badge; tint the whole fence block. */ +.cw-blk.cw-by-claude, .cw-blk.cw-by-human, .cw-blk.cw-mixed { outline: 2px solid currentColor; outline-offset: 2px; } +.cw-seg { + background: transparent; color: var(--vscode-foreground); + border: 1px solid var(--vscode-panel-border); padding: 0 0.5em; cursor: pointer; font-size: 0.9em; +} +.cw-seg:first-child { border-radius: 3px 0 0 3px; } +.cw-seg:last-child { border-radius: 0 3px 3px 0; border-left: none; } +.cw-seg-on { background: var(--vscode-button-background); color: var(--vscode-button-foreground); } +#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; } +``` +(Background tints use translucent fallbacks so they read against any theme; the inline spans are intentionally subtle.) + +- [ ] **Step 4: Build + verify** + +Run: `npm run build && npm run typecheck && npm test` +Expected: build emits `out/media/preview.js`; typecheck clean; unit green. + +- [ ] **Step 5: Commit** + +```bash +git add src/trackChangesPreview.ts media/preview.ts media/preview.css +git commit -m "feat(f9): webview segmented mode toggle + authorship legend + author CSS" +``` + +--- + +## Task 6: Host E2E + +**Files:** +- Create: `test/e2e/suite/authorship.test.ts` + +- [ ] **Step 1: Write the suite** + +```typescript +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, 300)); + +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal"); + return api; +} + +// F9 host E2E (no LLM): authorship mode marks Claude's landed span. Owns its own +// markdown doc, disjoint from the other suites' fixtures. +suite("F9 authorship preview (host E2E — seam ingress, no LLM)", () => { + const DOC_REL = "docs/f9authorship.md"; + const TARGET = "The sentence Claude will compose over."; + + test("authorship mode marks Claude's accepted edit; track-changes mode still works", async () => { + const abs = path.join(WS, DOC_REL); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, `# F9\n\n${TARGET}\n`, "utf8"); + const uri = vscode.Uri.file(abs); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await settle(); + const api = await getApi(); + const key = uri.toString(); + + // open the preview (track-changes mode by default) + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open"); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes", "defaults to track-changes"); + + // Claude composes via the seam (propose → accept) + const start = doc.getText().indexOf(TARGET); + const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { + uri: key, start, end: start + TARGET.length, newText: "The sentence CLAUDE COMPOSED.", model: "sonnet", sessionId: "e2e-f9", turnId: "turn-f9", + }); + assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies"); + await settle(); + + // attribution has a Claude span now + const claudeSpan = api.attributionController.getSpans(DOC_REL).find((s) => s.authorKind === "agent"); + assert.ok(claudeSpan, "Claude span recorded by F3"); + + // flip to authorship mode → the model should reflect Claude authorship + api.trackChangesPreviewController.setMode(key, "authorship"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "authorship"); + const spans = api.attributionController.spansFor(doc); + assert.ok(spans.some((s) => s.author === "claude"), "spansFor reports a Claude span for the preview"); + + // back to track-changes — still functional (regression) + api.trackChangesPreviewController.setMode(key, "changes"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes"); + assert.ok((api.trackChangesPreviewController.getLastModel(key) ?? []).length >= 1, "track-changes model still computed"); + }); +}); +``` + +- [ ] **Step 2: Run E2E** + +Run: `npm run test:e2e` +Expected: both EDH passes green (the new F9 suite + all prior suites). Debug per `superpowers:systematic-debugging` if red; do not weaken assertions. + +- [ ] **Step 3: Commit** + +```bash +git add test/e2e/suite/authorship.test.ts +git commit -m "test(f9): host E2E — authorship mode marks Claude's landed span" +``` + +--- + +## Task 7: Docs + +**Files:** +- Create: `docs/MANUAL-SMOKE-F9.md` +- Modify: `README.md` + +- [ ] **Step 1: `docs/MANUAL-SMOKE-F9.md`** + +```markdown +# Manual smoke — F9 authorship view in the preview + +Confirms the rendered preview's Authorship mode colors Claude's vs your text. One +live turn hits the SDK. + +## Prereqs +- `npm run build`; launch the Extension Development Host (F5) with `sandbox/` open. + +## Steps +1. Open a markdown file. Open **Cowriting: Open Track-Changes Preview** (`Ctrl+Alt+R`). +2. The header shows a `[ Track changes | Authorship ]` toggle. It opens in **Track + changes** (unchanged behavior). +3. Select a sentence → **Ask Claude to Edit Selection** → instruct → accept. +4. Click **Authorship**. Expect: Claude's accepted text is tinted (blue) and your + own typing is tinted (green); the header shows a legend (● Claude / ● You). + Text you never touched (original content) is plain. +5. Type a few words yourself, mixing into a Claude sentence. Expect: the colors + split mid-paragraph at the exact boundaries. +6. If the doc has a mermaid or code fence Claude authored, expect the whole fence + to carry a small author badge (not per-character). +7. Flip back to **Track changes**. Expect: the diff view is exactly as before. +``` + +- [ ] **Step 2: README F9 note** — add after the F8 section (match heading style): + +```markdown +## F9 — Authorship view in the preview (Feature ~#27) + +The rendered preview (F7) gains a second mode, switched by a `[ Track changes | +Authorship ]` toggle in its header. **Authorship** mode re-renders the current +document with each span colored by its F3 author — Claude (blue) vs you (green), +inline and char-precise — with a legend. Unlike track-changes (which diffs against +the F6 baseline, and so hides Claude's text once the baseline advances past a +landing), authorship reads F3 attribution directly, so Claude's contributions stay +visible. Code/mermaid fences carry a block-level author badge (atomic). Read-only, +sealed webview, no new persistence (INV-26..28). + +Design: `docs/superpowers/specs/2026-06-11-authorship-preview-design.md`. +Live smoke: [`docs/MANUAL-SMOKE-F9.md`](docs/MANUAL-SMOKE-F9.md). +``` + +- [ ] **Step 3: Verify + commit** + +```bash +npm run typecheck && npm test +git add docs/MANUAL-SMOKE-F9.md README.md +git commit -m "docs(f9): manual smoke + README authorship-mode note" +``` + +--- + +## Self-Review checklist (run before PR) + +- **Spec coverage:** §2 modes → Tasks 4/5; §3.1 render engine → Tasks 1/2; §3.2 wiring → Tasks 3/4/5; §3.3 INV-26/27/28 → Tasks 2/4; §5 testing → Tasks 1/2/6 + smoke; §6 slices → Tasks 1–7. +- **Untouched:** `renderTrackChanges`/`diffBlocks` logic, F6 baseline, F3 capture, the seam, persistence (`git diff --stat` should show no behavioral change to those). +- **Done (spec §6):** authorship mode marks Claude (blue) + you (green) inline; header toggle flips modes; fences get a block badge; track-changes unchanged; unit + host E2E green; smoke performed once. +``` -- 2.39.5 From 82d7c52983c7d9c097205002ed7d816db7fe7cae Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 14:34:40 -0700 Subject: [PATCH 3/9] =?UTF-8?q?feat(f9):=20splitBlocksWithRanges=20?= =?UTF-8?q?=E2=80=94=20block=20offsets=20aligned=20to=20the=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/trackChangesModel.ts | 70 ++++++++++++++++++++++++++++++++++ test/trackChangesModel.test.ts | 21 +++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 43801d0..30c25d7 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -70,6 +70,76 @@ export function splitBlocks(text: string): Block[] { return blocks; } +export interface BlockWithRange extends Block { + /** char offset of the block's first char in the source string. */ + start: number; + /** char offset one past the block's last char (source.slice(start,end) === raw). */ + end: number; +} + +/** + * Like splitBlocks, but each block carries its [start,end) char range in the + * SOURCE string (offsets align with document.getText(), so F3 attribution + * offsets map directly). Lines are tracked with their true source offsets — a + * trailing CR stays in the line, so a CRLF source keeps correct offsets. + */ +export function splitBlocksWithRanges(text: string): BlockWithRange[] { + const lines: { raw: string; start: number }[] = []; + let from = 0; + for (let i = 0; i <= text.length; i++) { + if (i === text.length || text[i] === "\n") { + lines.push({ raw: text.slice(from, i), start: from }); + from = i + 1; + if (i === text.length) break; + } + } + const rangeOf = (lo: number, hi: number): { start: number; end: number } => ({ + start: lines[lo].start, + end: lines[hi].start + lines[hi].raw.length, + }); + const out: BlockWithRange[] = []; + let buf: number[] = []; + const flushProse = () => { + if (buf.length) { + const { start, end } = rangeOf(buf[0], buf[buf.length - 1]); + const raw = text.slice(start, end); + if (raw.trim()) out.push({ ...makeBlock(raw, "prose"), start, end }); + } + buf = []; + }; + let i = 0; + while (i < lines.length) { + const line = lines[i].raw; + const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); + if (fence) { + flushProse(); + const marker = fence[2][0]; + const info = fence[3].trim().split(/\s+/)[0].toLowerCase(); + const open = i; + i++; + while (i < lines.length) { + const closed = lines[i].raw.trim().startsWith(marker.repeat(3)); + i++; + if (closed) break; + } + const close = i - 1; + const { start, end } = rangeOf(open, close); + const raw = text.slice(start, end); + out.push({ ...makeBlock(raw, info === "mermaid" ? "mermaid" : "code"), start, end }); + continue; + } + if (line.trim() === "") { + flushProse(); + i++; + continue; + } + buf.push(i); + i++; + } + flushProse(); + return out; +} + export type BlockOp = | { kind: "unchanged"; block: Block } | { kind: "added"; block: Block } diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index d74414f..f97ea06 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { splitBlocks, diffBlocks, renderTrackChanges } from "../src/trackChangesModel"; +import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges } from "../src/trackChangesModel"; describe("splitBlocks", () => { it("splits prose paragraphs on blank lines, dropping empties", () => { @@ -137,3 +137,22 @@ describe("error chip (PUC-6)", () => { expect(html).toContain("

Good one.

"); // the healthy block still rendered }); }); + +describe("splitBlocksWithRanges — block offsets align with the source string", () => { + it("each block's [start,end) slices back to its raw from the source", () => { + const text = "# Title\n\nA prose paragraph.\n\n```js\ncode()\n```\n"; + const blocks = splitBlocksWithRanges(text); + expect(blocks.map((b) => b.type)).toEqual(["prose", "prose", "code"]); + for (const b of blocks) { + expect(text.slice(b.start, b.end)).toBe(b.raw); + } + expect(blocks[1].raw).toBe("A prose paragraph."); + }); + + it("marks a mermaid fence and preserves its source offsets", () => { + const text = "intro\n\n```mermaid\ngraph TD; A-->B;\n```\n"; + const blocks = splitBlocksWithRanges(text); + expect(blocks[1].type).toBe("mermaid"); + expect(text.slice(blocks[1].start, blocks[1].end)).toBe(blocks[1].raw); + }); +}); -- 2.39.5 From 95eaf78891baeca448b4550c97e7e33af302480b Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 14:36:19 -0700 Subject: [PATCH 4/9] =?UTF-8?q?feat(f9):=20renderAuthorship=20=E2=80=94=20?= =?UTF-8?q?inline=20author=20spans=20+=20atomic=20fence=20badges=20(INV-26?= =?UTF-8?q?/27/28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUA sentinel injection through markdown-it; adjacent-span ordering handled; code/mermaid fences atomic with a block-level author badge. Co-Authored-By: Claude Opus 4.8 --- src/trackChangesModel.ts | 89 ++++++++++++++++++++++++++++++++++ test/trackChangesModel.test.ts | 78 ++++++++++++++++++++++++++++- 2 files changed, 166 insertions(+), 1 deletion(-) diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 30c25d7..1eb1765 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -266,6 +266,95 @@ function renderOp(op: BlockOp, render: (src: string) => string): string { return `
${badge}${inner}
`; } +export type AuthorKind = "claude" | "human"; +export interface AuthorSpan { + start: number; + end: number; + author: AuthorKind; +} + +// Private-Use-Area sentinels (never appear in real content; markdown-it passes +// them through as plain text). Paired open/close per author. +const SENT = { + claude: { open: "", close: "" }, + human: { open: "", close: "" }, +} as const; + +function isCloseSentinel(m: string): boolean { + return m === SENT.claude.close || m === SENT.human.close; +} + +function authorBadge(authors: Set): { cls: string; label: string } | null { + if (authors.size === 0) return null; + if (authors.size > 1) return { cls: "cw-mixed", label: "mixed" }; + const only = [...authors][0]; + return only === "claude" + ? { cls: "cw-by-claude", label: "Claude" } + : { cls: "cw-by-human", label: "You" }; +} + +/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */ +function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string { + const inserts: { at: number; marker: string }[] = []; + for (const s of spans) { + const lo = Math.max(0, s.start - blockStart); + const hi = Math.min(raw.length, s.end - blockStart); + if (hi <= lo) continue; + inserts.push({ at: lo, marker: SENT[s.author].open }); + inserts.push({ at: hi, marker: SENT[s.author].close }); + } + // Apply high offset → low so earlier offsets stay valid. At an equal offset + // (one span's close == the next's open), apply opens BEFORE closes so the + // close ends up left of the open: "…
…" for adjacent spans. + inserts.sort((a, b) => b.at - a.at || (isCloseSentinel(a.marker) ? 1 : -1)); + let out = raw; + for (const ins of inserts) out = out.slice(0, ins.at) + ins.marker + out.slice(ins.at); + return out; +} + +/** Replace the rendered sentinels with author tags. */ +function sentinelsToSpans(html: string): string { + return html + .split(SENT.claude.open).join('') + .split(SENT.claude.close).join("") + .split(SENT.human.open).join('') + .split(SENT.human.close).join(""); +} + +/** + * Pure authorship render (INV-26/28): the CURRENT text with each F3-attributed + * span colored by author. Prose blocks get inline ``; + * code/mermaid fences stay ATOMIC (INV-27) — an overlapping span yields a + * block-level author badge, never inner sentinels. Deterministic. + */ +export function renderAuthorship( + currentText: string, + spans: AuthorSpan[], + opts: RenderOptions = {}, +): string { + const render = opts.render ?? defaultRender; + const safe = (src: string): string => { + try { + return render(src); + } catch (err) { + return chip(err instanceof Error ? err.message : String(err)); + } + }; + return splitBlocksWithRanges(currentText) + .map((b) => { + const overlapping = spans.filter((s) => s.end > b.start && s.start < b.end); + if (b.type !== "prose") { + const badge = authorBadge(new Set(overlapping.map((s) => s.author))); + const inner = safe(b.raw); + if (!badge) return `
${inner}
`; + return `
${badge.label}${inner}
`; + } + const injected = injectSentinels(b.raw, b.start, overlapping); + return `
${sentinelsToSpans(safe(injected))}
`; + }) + .join("\n"); +} + /** Pure entry point: annotated HTML body for the preview (INV-22). */ export function renderTrackChanges( baselineText: string, diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index f97ea06..ddfc255 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges } from "../src/trackChangesModel"; +import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, renderAuthorship, type AuthorSpan } from "../src/trackChangesModel"; describe("splitBlocks", () => { it("splits prose paragraphs on blank lines, dropping empties", () => { @@ -156,3 +156,79 @@ describe("splitBlocksWithRanges — block offsets align with the source string", expect(text.slice(blocks[1].start, blocks[1].end)).toBe(blocks[1].raw); }); }); + +describe("renderAuthorship", () => { + const spanAt = (text: string, sub: string, author: "claude" | "human"): AuthorSpan => { + const start = text.indexOf(sub); + return { start, end: start + sub.length, author }; + }; + + it("empty spans → plain render (no author wrappers)", () => { + const text = "# Hi\n\nplain paragraph.\n"; + const html = renderAuthorship(text, []); + expect(html).not.toContain("cw-by-claude"); + expect(html).not.toContain("cw-by-human"); + expect(html).toContain("plain paragraph."); + }); + + it("wraps a single Claude span inline", () => { + const text = "The cat sat on the mat.\n"; + const html = renderAuthorship(text, [spanAt(text, "cat sat", "claude")]); + expect(html).toContain('cat sat'); + }); + + it("marks two authors in one paragraph at exact boundaries", () => { + const text = "Alpha beta gamma.\n"; + const html = renderAuthorship(text, [ + spanAt(text, "Alpha", "human"), + spanAt(text, "gamma", "claude"), + ]); + expect(html).toContain('Alpha'); + expect(html).toContain('gamma'); + }); + + it("handles two ADJACENT spans (one's end == next's start) in order", () => { + const text = "ONETWO\n"; + const html = renderAuthorship(text, [ + { start: 0, end: 3, author: "human" }, // ONE + { start: 3, end: 6, author: "claude" }, // TWO + ]); + expect(html).toContain('ONETWO'); + }); + + it("clips a span to its block (does not bleed across blocks)", () => { + const text = "Para one.\n\nPara two.\n"; + const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]); + expect(html).toContain('Para one.'); + expect(html).toContain('Para two.'); + }); + + it("a code fence overlapping a span gets a block badge, NOT inner sentinels (atomic)", () => { + const text = "```js\nconst x = 1;\n```\n"; + const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]); + expect(html).toContain("cw-by-claude"); + expect(html).toContain("cw-badge"); + expect(html).not.toMatch(/[\uE000-\uE003]/); // no sentinel leaked + expect(html).toContain("const x = 1;"); + }); + + it("a mermaid fence authored by Claude renders as a diagram with a badge", () => { + const text = "```mermaid\ngraph TD; A-->B;\n```\n"; + const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]); + expect(html).toContain('pre class="mermaid"'); + expect(html).toContain("cw-by-claude"); + expect(html).toContain("cw-badge"); + }); + + it("never leaks a raw sentinel into prose output", () => { + const text = "Some mixed text here.\n"; + const html = renderAuthorship(text, [spanAt(text, "mixed", "claude")]); + expect(html).not.toMatch(/[\uE000-\uE003]/); + }); + + it("is deterministic", () => { + const text = "Stable input paragraph.\n"; + const spans: AuthorSpan[] = [spanAt(text, "input", "claude")]; + expect(renderAuthorship(text, spans)).toBe(renderAuthorship(text, spans)); + }); +}); -- 2.39.5 From a84d72e69b8d3d88b95be351afde3fe040ac6522 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 14:36:36 -0700 Subject: [PATCH 5/9] =?UTF-8?q?feat(f9):=20AttributionController.spansFor?= =?UTF-8?q?=20=E2=80=94=20authorship=20spans=20for=20the=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/attributionController.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/attributionController.ts b/src/attributionController.ts index e62b310..8353fe8 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -18,6 +18,7 @@ import { applyChange, coalesce, type LiveSpan } from "./attributionTracker"; import { minimizeReplace, PendingEditRegistry } from "./pendingEdits"; import type { VersionGuard } from "./versionGuard"; import { isAuthorable } from "./workspacePath"; +import type { AuthorSpan } from "./trackChangesModel"; /** Test-facing snapshot of live attribution state for a document. */ export interface RenderedSpan { @@ -400,6 +401,19 @@ export class AttributionController implements vscode.Disposable { getOrphanCount(docPath: string): number { return this.docs.get(docPath)?.orphans.length ?? 0; } + + /** + * F9: the document's live attribution as authorship spans for the preview — + * current-buffer char ranges mapped to author kind (agent→claude). Computes + * the document key internally, so callers pass a TextDocument, not the key. + */ + spansFor(document: vscode.TextDocument): AuthorSpan[] { + return this.getSpans(this.keyOf(document)).map((s) => ({ + start: s.range.start, + end: s.range.end, + author: s.authorKind === "agent" ? "claude" : "human", + })); + } isVisible(): boolean { return this.visible; } -- 2.39.5 From 399e3c5f70ef4d74fffd5cb819f9898f7ca9bdab Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 14:38:05 -0700 Subject: [PATCH 6/9] feat(f9): preview gains authorship mode + attribution dep + setMode wiring Per-panel mode (default changes); refresh branches to renderAuthorship reading AttributionController.spansFor; webview setMode message; extension.ts reorders the F7 controller after attribution. getMode/setMode test seams. Co-Authored-By: Claude Opus 4.8 --- src/extension.ts | 21 ++++++++------- src/trackChangesPreview.ts | 54 +++++++++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 2a2e23e..f4a388e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -70,16 +70,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef // still works — PUC-5. const globalSidecarStore = new GlobalSidecarStore(baselineStorageDir ?? ""); - // --- F7: rendered track-changes preview (Feature #21) — workspace-INDEPENDENT --- - // Like F6, F7 works on any markdown doc (incl. untitled / out-of-folder), so it - // is constructed regardless of an open folder and its command is always live. - // It reuses the F6 baseline (INV-20) and adds no persistence. - const trackChangesPreviewController = new TrackChangesPreviewController( - diffViewController, - context.extensionUri, - ); - context.subscriptions.push(trackChangesPreviewController); - // --- F8: authoring on ANY document (in-folder, out-of-folder, untitled) --- // The router routes per-document: in-workspace file: → the committable repo // `.threads/` sidecar (CoauthorStore, INV-2); out-of-folder file: + untitled: @@ -99,6 +89,17 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const attributionController = new AttributionController(sidecarRouter, root, versionGuard); context.subscriptions.push(attributionController); + // --- F7: rendered track-changes preview (Feature #21) + F9 authorship mode --- + // Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline, + // INV-20). Constructed AFTER attribution so F9's authorship view can read F3 + // spans (AttributionController.spansFor). + const trackChangesPreviewController = new TrackChangesPreviewController( + diffViewController, + context.extensionUri, + attributionController, + ); + context.subscriptions.push(trackChangesPreviewController); + // --- F4: propose/accept (Feature #12) --- const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard); context.subscriptions.push(proposalController); diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index 1320432..be3defe 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -11,7 +11,8 @@ import * as path from "node:path"; import { randomBytes } from "node:crypto"; import * as vscode from "vscode"; import type { DiffViewController } from "./diffViewController"; -import { renderTrackChanges, diffBlocks, type BlockOp } from "./trackChangesModel"; +import type { AttributionController } from "./attributionController"; +import { renderTrackChanges, renderAuthorship, diffBlocks, type BlockOp } from "./trackChangesModel"; const VIEW_TYPE = "cowriting.trackChangesPreview"; const DEBOUNCE_MS = 150; @@ -21,10 +22,13 @@ export class TrackChangesPreviewController implements vscode.Disposable { private readonly panels = new Map(); private readonly lastModel = new Map(); private readonly debounces = new Map(); + /** F9: per-panel view mode — track-changes (default) or authorship. */ + private readonly mode = new Map(); constructor( private readonly diffView: DiffViewController, private readonly extensionUri: vscode.Uri, + private readonly attribution: AttributionController, ) { this.disposables.push( vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () => @@ -70,6 +74,18 @@ export class TrackChangesPreviewController implements vscode.Disposable { () => { this.panels.delete(key); this.lastModel.delete(key); + this.mode.delete(key); + }, + null, + this.disposables, + ); + // F9: the webview's header toggle posts the chosen mode back. + panel.webview.onDidReceiveMessage( + (m: { type?: string; mode?: "changes" | "authorship" }) => { + if (m?.type === "setMode" && (m.mode === "changes" || m.mode === "authorship")) { + this.mode.set(key, m.mode); + this.refresh(document); + } }, null, this.disposables, @@ -97,14 +113,30 @@ export class TrackChangesPreviewController implements vscode.Disposable { if (doc) this.refresh(doc); } - /** Recompute the model + post HTML to the panel (no-op if no panel). */ + /** Recompute the model + post HTML to the panel for its current mode (no-op if no panel). */ refresh(document: vscode.TextDocument): void { const key = document.uri.toString(); const panel = this.panels.get(key); if (!panel) return; - const baseline = this.diffView.getBaseline(key); - const baselineText = baseline?.text ?? document.getText(); // no baseline → no marks + const mode = this.mode.get(key) ?? "changes"; const current = document.getText(); + if (mode === "authorship") { + // F9: render the current buffer colored by F3 author, baseline-independent. + const spans = this.attribution.spansFor(document); + this.lastModel.set(key, diffBlocks(this.diffView.getBaseline(key)?.text ?? current, current)); + void panel.webview.postMessage({ + type: "render", + mode, + html: renderAuthorship(current, spans), + legend: { + claude: spans.some((s) => s.author === "claude"), + human: spans.some((s) => s.author === "human"), + }, + }); + return; + } + const baseline = this.diffView.getBaseline(key); + const baselineText = baseline?.text ?? current; // no baseline → no marks const ops = diffBlocks(baselineText, current); this.lastModel.set(key, ops); const summary = { @@ -112,11 +144,11 @@ export class TrackChangesPreviewController implements vscode.Disposable { removed: ops.filter((o) => o.kind === "removed").length, changed: ops.filter((o) => o.kind === "changed").length, }; - const epoch = this.epochLabel(baseline); void panel.webview.postMessage({ type: "render", + mode, html: renderTrackChanges(baselineText, current), - epoch, + epoch: this.epochLabel(baseline), summary, }); } @@ -177,6 +209,16 @@ export class TrackChangesPreviewController implements vscode.Disposable { getLastModel(uriString: string): BlockOp[] | undefined { return this.lastModel.get(uriString); } + /** F9: current view mode for a panel (default track-changes). */ + getMode(uriString: string): "changes" | "authorship" { + return this.mode.get(uriString) ?? "changes"; + } + /** F9: set the view mode and re-render (the programmatic twin of the header toggle). */ + setMode(uriString: string, mode: "changes" | "authorship"): void { + this.mode.set(uriString, mode); + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); + if (doc) this.refresh(doc); + } dispose(): void { for (const t of this.debounces.values()) clearTimeout(t); -- 2.39.5 From e0118bb835f3661f9aee9582ca446f709f508e8a Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 14:38:50 -0700 Subject: [PATCH 7/9] feat(f9): webview segmented mode toggle + authorship legend + author CSS Co-Authored-By: Claude Opus 4.8 --- media/preview.css | 13 +++++++++++++ media/preview.ts | 37 +++++++++++++++++++++++++++++++------ src/trackChangesPreview.ts | 5 +++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/media/preview.css b/media/preview.css index 655e29c..8bd5d59 100644 --- a/media/preview.css +++ b/media/preview.css @@ -51,3 +51,16 @@ del, .cw-removed { } pre.mermaid { text-align: center; background: transparent; } pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); } + +/* F9 authorship mode — inline author tints + block-level fence badges + toggle. */ +.cw-by-claude { background: var(--vscode-editorInfo-foreground, rgba(64, 120, 242, 0.18)); text-decoration: none; } +.cw-by-human { background: var(--vscode-gitDecoration-addedResourceForeground, rgba(46, 160, 67, 0.18)); text-decoration: none; } +.cw-blk.cw-by-claude, .cw-blk.cw-by-human, .cw-blk.cw-mixed { outline: 2px solid currentColor; outline-offset: 2px; background: transparent; } +.cw-seg { + background: transparent; color: var(--vscode-foreground); + border: 1px solid var(--vscode-panel-border); padding: 0 0.5em; cursor: pointer; font-size: 0.9em; +} +.cw-seg:first-child { border-radius: 3px 0 0 3px; } +.cw-seg:last-child { border-radius: 0 3px 3px 0; border-left: none; } +.cw-seg-on { background: var(--vscode-button-background); color: var(--vscode-button-foreground); } +#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; } diff --git a/media/preview.ts b/media/preview.ts index 105f4a3..4492857 100644 --- a/media/preview.ts +++ b/media/preview.ts @@ -10,16 +10,29 @@ import mermaid from "mermaid"; // links it into the sealed shell via asWebviewUri). import "./preview.css"; +declare function acquireVsCodeApi(): { postMessage(m: unknown): void }; + interface RenderMessage { type: "render"; + mode: "changes" | "authorship"; html: string; - epoch: string; - summary: { added: number; removed: number; changed: number }; + epoch?: string; + summary?: { added: number; removed: number; changed: number }; + legend?: { claude: boolean; human: boolean }; } +const vscodeApi = acquireVsCodeApi(); const body = document.getElementById("cw-body")!; const header = document.getElementById("cw-epoch")!; const summary = document.getElementById("cw-summary")!; +const legend = document.getElementById("cw-legend")!; +const segs = Array.from(document.querySelectorAll(".cw-seg")); + +for (const seg of segs) { + seg.addEventListener("click", () => { + vscodeApi.postMessage({ type: "setMode", mode: seg.dataset.mode }); + }); +} function themeFor(): "dark" | "default" { return document.body.classList.contains("vscode-dark") || @@ -46,9 +59,21 @@ window.addEventListener("message", (event: MessageEvent) => { const msg = event.data; if (msg?.type !== "render") return; body.innerHTML = msg.html; - header.textContent = `Track changes since ${msg.epoch}`; - summary.innerHTML = - `+${msg.summary.added + msg.summary.changed} ` + - `−${msg.summary.removed + msg.summary.changed}`; + for (const seg of segs) seg.classList.toggle("cw-seg-on", seg.dataset.mode === msg.mode); + const authorship = msg.mode === "authorship"; + header.hidden = authorship; + summary.hidden = authorship; + legend.hidden = !authorship; + if (authorship) { + const parts: string[] = []; + if (msg.legend?.claude) parts.push('Claude'); + if (msg.legend?.human) parts.push('You'); + legend.innerHTML = parts.join(" ") || "no attribution yet"; + } else { + header.textContent = `Track changes since ${msg.epoch ?? ""}`; + summary.innerHTML = + `+${(msg.summary?.added ?? 0) + (msg.summary?.changed ?? 0)} ` + + `−${(msg.summary?.removed ?? 0) + (msg.summary?.changed ?? 0)}`; + } void renderMermaid(); }); diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index be3defe..e4fd7ba 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -193,8 +193,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
+
+ + +
Track changes +
-- 2.39.5 From 4212d900554f4462cd02e749b5acc16cfcbd6ae2 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 14:39:54 -0700 Subject: [PATCH 8/9] =?UTF-8?q?test(f9):=20host=20E2E=20=E2=80=94=20author?= =?UTF-8?q?ship=20mode=20marks=20Claude's=20landed=20span?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- test/e2e/suite/authorship.test.ts | 74 +++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 test/e2e/suite/authorship.test.ts diff --git a/test/e2e/suite/authorship.test.ts b/test/e2e/suite/authorship.test.ts new file mode 100644 index 0000000..b37f4d7 --- /dev/null +++ b/test/e2e/suite/authorship.test.ts @@ -0,0 +1,74 @@ +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, 300)); + +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal"); + return api; +} + +// F9 host E2E (no LLM): authorship mode reflects Claude's landed span. Owns its +// own markdown doc, disjoint from the other suites' fixtures. +suite("F9 authorship preview (host E2E — seam ingress, no LLM)", () => { + const DOC_REL = "docs/f9authorship.md"; + const TARGET = "The sentence Claude will compose over."; + + test("authorship mode marks Claude's accepted edit; track-changes mode still works", async () => { + const abs = path.join(WS, DOC_REL); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, `# F9\n\n${TARGET}\n`, "utf8"); + const uri = vscode.Uri.file(abs); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await settle(); + const api = await getApi(); + const key = uri.toString(); + + // open the preview (track-changes mode by default) + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open"); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes", "defaults to track-changes"); + + // Claude composes via the seam (propose → accept) + const start = doc.getText().indexOf(TARGET); + const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { + uri: key, + start, + end: start + TARGET.length, + newText: "The sentence CLAUDE COMPOSED.", + model: "sonnet", + sessionId: "e2e-f9", + turnId: "turn-f9", + }); + assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies"); + await settle(); + + // attribution has a Claude span now + const claudeSpan = api.attributionController.getSpans(DOC_REL).find((s) => s.authorKind === "agent"); + assert.ok(claudeSpan, "Claude span recorded by F3"); + + // flip to authorship mode → the preview reads a Claude span + api.trackChangesPreviewController.setMode(key, "authorship"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "authorship"); + const spans = api.attributionController.spansFor(doc); + assert.ok(spans.some((s) => s.author === "claude"), "spansFor reports a Claude span for the preview"); + + // back to track-changes — still functional (regression) + api.trackChangesPreviewController.setMode(key, "changes"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes"); + assert.ok( + (api.trackChangesPreviewController.getLastModel(key) ?? []).length >= 1, + "track-changes model still computed", + ); + }); +}); -- 2.39.5 From 001adee34f0ed4da7267c3b58bd5be6a8cfcf122 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 14:40:21 -0700 Subject: [PATCH 9/9] docs(f9): manual smoke + README authorship-mode note Co-Authored-By: Claude Opus 4.8 --- README.md | 14 ++++++++++++++ docs/MANUAL-SMOKE-F9.md | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 docs/MANUAL-SMOKE-F9.md diff --git a/README.md b/README.md index 403b223..4b6c82b 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,20 @@ the manual smoke, not the sealed-sandbox E2E. Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`. Live smoke: [`docs/MANUAL-SMOKE-F7.md`](docs/MANUAL-SMOKE-F7.md). +## F9 — Authorship view in the preview (Feature ~#27) + +The rendered preview (F7) gains a second mode, switched by a `[ Track changes | +Authorship ]` toggle in its header. **Authorship** mode re-renders the current +document with each span colored by its F3 author — Claude (blue) vs you (green), +inline and char-precise — with a legend. Unlike track-changes (which diffs against +the F6 baseline, and so hides Claude's text once the baseline advances past a +landing), authorship reads F3 attribution directly, so Claude's contributions stay +visible. Code/mermaid fences carry a block-level author badge (atomic). Read-only, +sealed webview, no new persistence (INV-26..28). + +Design: `docs/superpowers/specs/2026-06-11-authorship-preview-design.md`. +Live smoke: [`docs/MANUAL-SMOKE-F9.md`](docs/MANUAL-SMOKE-F9.md). + ## F8 — Out-of-workspace authoring (Feature #25) "Ask Claude to Edit Selection" (and F2 threads / F3 attribution / F4 diff --git a/docs/MANUAL-SMOKE-F9.md b/docs/MANUAL-SMOKE-F9.md new file mode 100644 index 0000000..3d2e5d0 --- /dev/null +++ b/docs/MANUAL-SMOKE-F9.md @@ -0,0 +1,21 @@ +# Manual smoke — F9 authorship view in the preview + +Confirms the rendered preview's Authorship mode colors Claude's vs your text. One +live turn hits the SDK. + +## Prereqs +- `npm run build`; launch the Extension Development Host (F5) with `sandbox/` open. + +## Steps +1. Open a markdown file. Open **Cowriting: Open Track-Changes Preview** (`Ctrl+Alt+R`). +2. The header shows a `[ Track changes | Authorship ]` toggle. It opens in **Track + changes** (unchanged behavior). +3. Select a sentence → **Ask Claude to Edit Selection** → instruct → accept. +4. Click **Authorship**. Expect: Claude's accepted text is tinted (blue) and your + own typing is tinted (green); the header shows a legend (Claude / You). Text you + never touched (original content) is plain. +5. Type a few words yourself, mixing into a Claude sentence. Expect: the colors + split mid-paragraph at the exact boundaries. +6. If the doc has a mermaid or code fence Claude authored, expect the whole fence + to carry a small author badge (not per-character). +7. Flip back to **Track changes**. Expect: the diff view is exactly as before. -- 2.39.5