F9: authorship view in the rendered preview #27
@@ -196,6 +196,20 @@ the manual smoke, not the sealed-sandbox E2E.
|
|||||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`.
|
Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`.
|
||||||
Live smoke: [`docs/MANUAL-SMOKE-F7.md`](docs/MANUAL-SMOKE-F7.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)
|
## F8 — Out-of-workspace authoring (Feature #25)
|
||||||
|
|
||||||
"Ask Claude to Edit Selection" (and F2 threads / F3 attribution / F4
|
"Ask Claude to Edit Selection" (and F2 threads / F3 attribution / F4
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 `<span class="cw-by-claude|cw-by-human">`; 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('<span class="cw-by-claude">cat sat</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<span class="cw-by-human">Alpha</span>');
|
||||||
|
expect(html).toContain('<span class="cw-by-claude">gamma</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<span class="cw-by-claude">Para one.</span>');
|
||||||
|
expect(html).toContain('<span class="cw-by-claude">Para two.</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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<AuthorKind>): { 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 <span> tags. */
|
||||||
|
function sentinelsToSpans(html: string): string {
|
||||||
|
return html
|
||||||
|
.split(SENT.claude.open).join('<span class="cw-by-claude">')
|
||||||
|
.split(SENT.claude.close).join("</span>")
|
||||||
|
.split(SENT.human.open).join('<span class="cw-by-human">')
|
||||||
|
.split(SENT.human.close).join("</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure authorship render (INV-26/28): the CURRENT text with each F3-attributed
|
||||||
|
* span colored by author. Prose blocks get inline `<span class="cw-by-*">`;
|
||||||
|
* 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 `<div class="cw-blk">${inner}</div>`;
|
||||||
|
return `<div class="cw-blk ${badge.cls}"><span class="cw-badge">${badge.label}</span>${inner}</div>`;
|
||||||
|
}
|
||||||
|
const injected = injectSentinels(b.raw, b.start, overlapping);
|
||||||
|
return `<div class="cw-blk">${sentinelsToSpans(safe(injected))}</div>`;
|
||||||
|
})
|
||||||
|
.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 `<p>`, the assertion still holds (`<span ...>cat sat</span>` appears inside the `<p>`). 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<string, "changes" | "authorship">();
|
||||||
|
```
|
||||||
|
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 `<div id="cw-header">…</div>` with:
|
||||||
|
```html
|
||||||
|
<div id="cw-header">
|
||||||
|
<div id="cw-mode" role="group">
|
||||||
|
<button id="cw-mode-changes" class="cw-seg cw-seg-on" data-mode="changes">Track changes</button>
|
||||||
|
<button id="cw-mode-authorship" class="cw-seg" data-mode="authorship">Authorship</button>
|
||||||
|
</div>
|
||||||
|
<span id="cw-epoch">Track changes</span>
|
||||||
|
<span id="cw-summary"></span>
|
||||||
|
<span id="cw-legend" hidden></span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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<HTMLButtonElement>(".cw-seg"));
|
||||||
|
|
||||||
|
for (const seg of segs) {
|
||||||
|
seg.addEventListener("click", () => {
|
||||||
|
vscodeApi.postMessage({ type: "setMode", mode: seg.dataset.mode });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
|
||||||
|
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('<span class="cw-by-claude cw-swatch">Claude</span>');
|
||||||
|
if (msg.legend?.human) parts.push('<span class="cw-by-human cw-swatch">You</span>');
|
||||||
|
legend.innerHTML = parts.join(" ") || "no attribution yet";
|
||||||
|
} else {
|
||||||
|
header.textContent = `Track changes since ${msg.epoch ?? ""}`;
|
||||||
|
summary.innerHTML =
|
||||||
|
`<span class="cw-add">+${(msg.summary?.added ?? 0) + (msg.summary?.changed ?? 0)}</span> ` +
|
||||||
|
`<span class="cw-del">−${(msg.summary?.removed ?? 0) + (msg.summary?.changed ?? 0)}</span>`;
|
||||||
|
}
|
||||||
|
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<CowritingApi> {
|
||||||
|
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<string>("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.
|
||||||
|
```
|
||||||
@@ -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 `<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.
|
||||||
@@ -51,3 +51,16 @@ del, .cw-removed {
|
|||||||
}
|
}
|
||||||
pre.mermaid { text-align: center; background: transparent; }
|
pre.mermaid { text-align: center; background: transparent; }
|
||||||
pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
|
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; }
|
||||||
|
|||||||
+30
-5
@@ -10,16 +10,29 @@ import mermaid from "mermaid";
|
|||||||
// links it into the sealed shell via asWebviewUri).
|
// links it into the sealed shell via asWebviewUri).
|
||||||
import "./preview.css";
|
import "./preview.css";
|
||||||
|
|
||||||
|
declare function acquireVsCodeApi(): { postMessage(m: unknown): void };
|
||||||
|
|
||||||
interface RenderMessage {
|
interface RenderMessage {
|
||||||
type: "render";
|
type: "render";
|
||||||
|
mode: "changes" | "authorship";
|
||||||
html: string;
|
html: string;
|
||||||
epoch: string;
|
epoch?: string;
|
||||||
summary: { added: number; removed: number; changed: number };
|
summary?: { added: number; removed: number; changed: number };
|
||||||
|
legend?: { claude: boolean; human: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const vscodeApi = acquireVsCodeApi();
|
||||||
const body = document.getElementById("cw-body")!;
|
const body = document.getElementById("cw-body")!;
|
||||||
const header = document.getElementById("cw-epoch")!;
|
const header = document.getElementById("cw-epoch")!;
|
||||||
const summary = document.getElementById("cw-summary")!;
|
const summary = document.getElementById("cw-summary")!;
|
||||||
|
const legend = document.getElementById("cw-legend")!;
|
||||||
|
const segs = Array.from(document.querySelectorAll<HTMLButtonElement>(".cw-seg"));
|
||||||
|
|
||||||
|
for (const seg of segs) {
|
||||||
|
seg.addEventListener("click", () => {
|
||||||
|
vscodeApi.postMessage({ type: "setMode", mode: seg.dataset.mode });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function themeFor(): "dark" | "default" {
|
function themeFor(): "dark" | "default" {
|
||||||
return document.body.classList.contains("vscode-dark") ||
|
return document.body.classList.contains("vscode-dark") ||
|
||||||
@@ -46,9 +59,21 @@ window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
|
|||||||
const msg = event.data;
|
const msg = event.data;
|
||||||
if (msg?.type !== "render") return;
|
if (msg?.type !== "render") return;
|
||||||
body.innerHTML = msg.html;
|
body.innerHTML = msg.html;
|
||||||
header.textContent = `Track changes since ${msg.epoch}`;
|
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('<span class="cw-by-claude cw-swatch">Claude</span>');
|
||||||
|
if (msg.legend?.human) parts.push('<span class="cw-by-human cw-swatch">You</span>');
|
||||||
|
legend.innerHTML = parts.join(" ") || "no attribution yet";
|
||||||
|
} else {
|
||||||
|
header.textContent = `Track changes since ${msg.epoch ?? ""}`;
|
||||||
summary.innerHTML =
|
summary.innerHTML =
|
||||||
`<span class="cw-add">+${msg.summary.added + msg.summary.changed}</span> ` +
|
`<span class="cw-add">+${(msg.summary?.added ?? 0) + (msg.summary?.changed ?? 0)}</span> ` +
|
||||||
`<span class="cw-del">−${msg.summary.removed + msg.summary.changed}</span>`;
|
`<span class="cw-del">−${(msg.summary?.removed ?? 0) + (msg.summary?.changed ?? 0)}</span>`;
|
||||||
|
}
|
||||||
void renderMermaid();
|
void renderMermaid();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { applyChange, coalesce, type LiveSpan } from "./attributionTracker";
|
|||||||
import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
|
import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
|
||||||
import type { VersionGuard } from "./versionGuard";
|
import type { VersionGuard } from "./versionGuard";
|
||||||
import { isAuthorable } from "./workspacePath";
|
import { isAuthorable } from "./workspacePath";
|
||||||
|
import type { AuthorSpan } from "./trackChangesModel";
|
||||||
|
|
||||||
/** Test-facing snapshot of live attribution state for a document. */
|
/** Test-facing snapshot of live attribution state for a document. */
|
||||||
export interface RenderedSpan {
|
export interface RenderedSpan {
|
||||||
@@ -400,6 +401,19 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
getOrphanCount(docPath: string): number {
|
getOrphanCount(docPath: string): number {
|
||||||
return this.docs.get(docPath)?.orphans.length ?? 0;
|
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 {
|
isVisible(): boolean {
|
||||||
return this.visible;
|
return this.visible;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-10
@@ -70,16 +70,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
// still works — PUC-5.
|
// still works — PUC-5.
|
||||||
const globalSidecarStore = new GlobalSidecarStore(baselineStorageDir ?? "");
|
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) ---
|
// --- F8: authoring on ANY document (in-folder, out-of-folder, untitled) ---
|
||||||
// The router routes per-document: in-workspace file: → the committable repo
|
// The router routes per-document: in-workspace file: → the committable repo
|
||||||
// `.threads/` sidecar (CoauthorStore, INV-2); out-of-folder file: + untitled:
|
// `.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);
|
const attributionController = new AttributionController(sidecarRouter, root, versionGuard);
|
||||||
context.subscriptions.push(attributionController);
|
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) ---
|
// --- F4: propose/accept (Feature #12) ---
|
||||||
const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard);
|
const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard);
|
||||||
context.subscriptions.push(proposalController);
|
context.subscriptions.push(proposalController);
|
||||||
|
|||||||
@@ -70,6 +70,76 @@ export function splitBlocks(text: string): Block[] {
|
|||||||
return blocks;
|
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 =
|
export type BlockOp =
|
||||||
| { kind: "unchanged"; block: Block }
|
| { kind: "unchanged"; block: Block }
|
||||||
| { kind: "added"; block: Block }
|
| { kind: "added"; block: Block }
|
||||||
@@ -196,6 +266,95 @@ function renderOp(op: BlockOp, render: (src: string) => string): string {
|
|||||||
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
|
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<AuthorKind>): { 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: "…</span><span>…" 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 <span> tags. */
|
||||||
|
function sentinelsToSpans(html: string): string {
|
||||||
|
return html
|
||||||
|
.split(SENT.claude.open).join('<span class="cw-by-claude">')
|
||||||
|
.split(SENT.claude.close).join("</span>")
|
||||||
|
.split(SENT.human.open).join('<span class="cw-by-human">')
|
||||||
|
.split(SENT.human.close).join("</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure authorship render (INV-26/28): the CURRENT text with each F3-attributed
|
||||||
|
* span colored by author. Prose blocks get inline `<span class="cw-by-*">`;
|
||||||
|
* 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 `<div class="cw-blk">${inner}</div>`;
|
||||||
|
return `<div class="cw-blk ${badge.cls}"><span class="cw-badge">${badge.label}</span>${inner}</div>`;
|
||||||
|
}
|
||||||
|
const injected = injectSentinels(b.raw, b.start, overlapping);
|
||||||
|
return `<div class="cw-blk">${sentinelsToSpans(safe(injected))}</div>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
/** Pure entry point: annotated HTML body for the preview (INV-22). */
|
/** Pure entry point: annotated HTML body for the preview (INV-22). */
|
||||||
export function renderTrackChanges(
|
export function renderTrackChanges(
|
||||||
baselineText: string,
|
baselineText: string,
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import * as path from "node:path";
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import type { DiffViewController } from "./diffViewController";
|
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 VIEW_TYPE = "cowriting.trackChangesPreview";
|
||||||
const DEBOUNCE_MS = 150;
|
const DEBOUNCE_MS = 150;
|
||||||
@@ -21,10 +22,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
private readonly panels = new Map<string, vscode.WebviewPanel>();
|
private readonly panels = new Map<string, vscode.WebviewPanel>();
|
||||||
private readonly lastModel = new Map<string, BlockOp[]>();
|
private readonly lastModel = new Map<string, BlockOp[]>();
|
||||||
private readonly debounces = new Map<string, NodeJS.Timeout>();
|
private readonly debounces = new Map<string, NodeJS.Timeout>();
|
||||||
|
/** F9: per-panel view mode — track-changes (default) or authorship. */
|
||||||
|
private readonly mode = new Map<string, "changes" | "authorship">();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly diffView: DiffViewController,
|
private readonly diffView: DiffViewController,
|
||||||
private readonly extensionUri: vscode.Uri,
|
private readonly extensionUri: vscode.Uri,
|
||||||
|
private readonly attribution: AttributionController,
|
||||||
) {
|
) {
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
|
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
|
||||||
@@ -70,6 +74,18 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
() => {
|
() => {
|
||||||
this.panels.delete(key);
|
this.panels.delete(key);
|
||||||
this.lastModel.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,
|
null,
|
||||||
this.disposables,
|
this.disposables,
|
||||||
@@ -97,14 +113,30 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
if (doc) this.refresh(doc);
|
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 {
|
refresh(document: vscode.TextDocument): void {
|
||||||
const key = document.uri.toString();
|
const key = document.uri.toString();
|
||||||
const panel = this.panels.get(key);
|
const panel = this.panels.get(key);
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
const baseline = this.diffView.getBaseline(key);
|
const mode = this.mode.get(key) ?? "changes";
|
||||||
const baselineText = baseline?.text ?? document.getText(); // no baseline → no marks
|
|
||||||
const current = document.getText();
|
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);
|
const ops = diffBlocks(baselineText, current);
|
||||||
this.lastModel.set(key, ops);
|
this.lastModel.set(key, ops);
|
||||||
const summary = {
|
const summary = {
|
||||||
@@ -112,11 +144,11 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
removed: ops.filter((o) => o.kind === "removed").length,
|
removed: ops.filter((o) => o.kind === "removed").length,
|
||||||
changed: ops.filter((o) => o.kind === "changed").length,
|
changed: ops.filter((o) => o.kind === "changed").length,
|
||||||
};
|
};
|
||||||
const epoch = this.epochLabel(baseline);
|
|
||||||
void panel.webview.postMessage({
|
void panel.webview.postMessage({
|
||||||
type: "render",
|
type: "render",
|
||||||
|
mode,
|
||||||
html: renderTrackChanges(baselineText, current),
|
html: renderTrackChanges(baselineText, current),
|
||||||
epoch,
|
epoch: this.epochLabel(baseline),
|
||||||
summary,
|
summary,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -161,8 +193,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="cw-header">
|
<div id="cw-header">
|
||||||
|
<div id="cw-mode" role="group">
|
||||||
|
<button id="cw-mode-changes" class="cw-seg cw-seg-on" data-mode="changes">Track changes</button>
|
||||||
|
<button id="cw-mode-authorship" class="cw-seg" data-mode="authorship">Authorship</button>
|
||||||
|
</div>
|
||||||
<span id="cw-epoch">Track changes</span>
|
<span id="cw-epoch">Track changes</span>
|
||||||
<span id="cw-summary"></span>
|
<span id="cw-summary"></span>
|
||||||
|
<span id="cw-legend" hidden></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="cw-body"></div>
|
<div id="cw-body"></div>
|
||||||
<script nonce="${nonce}" src="${scriptUri}"></script>
|
<script nonce="${nonce}" src="${scriptUri}"></script>
|
||||||
@@ -177,6 +214,16 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
getLastModel(uriString: string): BlockOp[] | undefined {
|
getLastModel(uriString: string): BlockOp[] | undefined {
|
||||||
return this.lastModel.get(uriString);
|
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 {
|
dispose(): void {
|
||||||
for (const t of this.debounces.values()) clearTimeout(t);
|
for (const t of this.debounces.values()) clearTimeout(t);
|
||||||
|
|||||||
@@ -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<CowritingApi> {
|
||||||
|
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<string>("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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { splitBlocks, diffBlocks, renderTrackChanges } from "../src/trackChangesModel";
|
import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, renderAuthorship, type AuthorSpan } from "../src/trackChangesModel";
|
||||||
|
|
||||||
describe("splitBlocks", () => {
|
describe("splitBlocks", () => {
|
||||||
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
||||||
@@ -137,3 +137,98 @@ describe("error chip (PUC-6)", () => {
|
|||||||
expect(html).toContain("<p>Good one.</p>"); // the healthy block still rendered
|
expect(html).toContain("<p>Good one.</p>"); // 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<span class="cw-by-claude">cat sat</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<span class="cw-by-human">Alpha</span>');
|
||||||
|
expect(html).toContain('<span class="cw-by-claude">gamma</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<span class="cw-by-human">ONE</span><span class="cw-by-claude">TWO</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<span class="cw-by-claude">Para one.</span>');
|
||||||
|
expect(html).toContain('<span class="cw-by-claude">Para two.</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user