diff --git a/README.md b/README.md index 4b6c82b..405e96f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ catalog (a pure, key-free SDK call) in a notification and the Features shipped so far: F2 region-anchored threads (Feature #4), F3 live human/Claude attribution (Feature #6), F4 propose/accept diff flow (Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view -toggle (Feature #17). +toggle (Feature #17), and F10 interactive review — **write left / review +right** (Feature #29). ## Architecture @@ -237,6 +238,30 @@ a plain PR revert with zero data migration. Design: `vscode-cowriting-plugin-content/specs/coauthoring-out-of-workspace.md`. Live smoke: [`docs/MANUAL-SMOKE-F8.md`](docs/MANUAL-SMOKE-F8.md). +## F10 — Interactive review: write left / review right (Feature #29) + +A clean, **zero-annotation editor** on the left; the rendered preview on the +right as the **single interactive review surface**. The editor carries no +attribution tint, no in-editor proposal threads, and no diff — all review lives +in the preview, toggled by the **Annotations** switch in its header (on by +default). `Ctrl+Alt+R` opens **"Open Review Preview"**. + +In the on-state the preview shows **green = human / blue = Claude / +strikethrough = deleted**, and surfaces each of Claude's pending F4 proposals as +a blue `cw-proposal` block with **✓ / ✗** buttons: **✓** accepts (the +replacement lands Claude-attributed via the seam and the baseline advances past +it), **✗** rejects (the block vanishes, the document untouched). With no preview +open, a status-bar indicator shows the pending-proposal count and opens the +review when clicked. Toggle **Annotations** off for clean rendered markdown. +Read-only, sealed webview, no new persistence (INV-32..34). + +F6's two-pane diff and F9's authorship view are **retained only as data layers** +(the baseline the preview diffs against; the F3 attribution that colors it) — +they are no longer separate user surfaces. + +Design: `vscode-cowriting-plugin-content/specs/coauthoring-interactive-review.md`. +Live smoke: [`docs/MANUAL-SMOKE-F10.md`](docs/MANUAL-SMOKE-F10.md). + ## Develop - `npm run watch` — rebuild on change. diff --git a/docs/MANUAL-SMOKE-F10.md b/docs/MANUAL-SMOKE-F10.md new file mode 100644 index 0000000..62c5d4f --- /dev/null +++ b/docs/MANUAL-SMOKE-F10.md @@ -0,0 +1,58 @@ +# Manual smoke — F10 interactive review in the preview (#29) + +F10 makes the rendered preview the **single interactive review surface**: the +editor is clean (zero annotations), and you accept/reject Claude's proposals +*inside the preview*. The webview's *visual* rendering (theming, ✓/✗ buttons) is +verified here, not in the automated host E2E (the webview is a sealed sandbox). +Run once per change that touches F10. One live turn hits the SDK (or use the +`proposeAgentEdit` seam to stay key-free). + +## Setup + +1. `npm run build` +2. Launch the Extension Development Host (F5 in VS Code, or the Run panel) with + `sandbox/` open. +3. Open a markdown document containing some prose (e.g. copy + `test/e2e/fixtures/workspace/docs/preview.md`). + +## Steps + +1. **Clean editor.** Look at the source editor: there is **no attribution tint, + no in-editor proposal comment threads, and no diff** — the editor is a plain + text buffer (F10/INV-32). All review lives in the preview. +2. **Open the preview.** Run **"Cowriting: Open Review Preview"** (or + `Ctrl+Alt+R`). A preview opens beside the editor. The header shows an + **Annotations** switch (on by default) and a summary. +3. **Edit prose.** In the source editor, change a word in a paragraph. The + preview updates (≈150 ms) in its on-state: the new word highlighted as a green + insertion (`` / `cw-by-human`), the old word struck (`` / `cw-del`); + the summary increments. Your own typing is colored green (human). +4. **Ask Claude to edit a selection.** Select a sentence → **"Ask Claude to Edit + Selection"** → instruct (or invoke the `proposeAgentEdit` seam). A **blue + `cw-proposal` block** appears in the preview, showing the struck replaced text + and the proposed replacement, with **✓ / ✗** buttons in a `cw-actions` span. + The editor itself does **not** change (INV-10 — propose never mutates the doc). +5. **Accept one.** Click **✓** on the proposal. Expect: the replacement **lands + in the document** (the editor text updates), the proposal block **clears** from + the preview, and the landed Claude text is **not** marked as a change (the + baseline advanced past the landing; INV-18). +6. **Reject another.** Propose a second edit, then click **✗** on it. Expect: the + block **vanishes** from the preview and the **document is unchanged**. +7. **Toggle Annotations off.** Flip the header **Annotations** switch off. Expect: + the preview shows **clean rendered markdown** — no green/blue author colors, no + struck deletions, no proposal blocks (INV-33). Flip it **on** again: the marks + and any pending proposal blocks return. +8. **Status-bar indicator (PUC-6).** Close the preview. With a **pending + proposal** outstanding and **no preview open**, a status-bar item shows the + pending count (e.g. "1 Claude proposal"). **Click it** — the review preview + opens and the indicator disappears. +9. **Theme.** Toggle light / dark / high-contrast (`Ctrl+K Ctrl+T`). The proposal + block and its ✓ / ✗ buttons, and the green/blue author colors, restyle to the + theme and stay legible in each. +10. **Cleanliness.** `git status` shows nothing written to the document, sidecar, + or repo by the preview (INV-20). + +## Pass criteria + +All ten steps behave as described; no console errors in the webview devtools; +the editor stays decoration-free throughout; nothing is persisted by the preview. diff --git a/docs/superpowers/plans/2026-06-11-f10-interactive-review.md b/docs/superpowers/plans/2026-06-11-f10-interactive-review.md new file mode 100644 index 0000000..6aa3da0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-f10-interactive-review.md @@ -0,0 +1,995 @@ +# F10 — Interactive Track-Changes Review in the Markdown 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:** Make the rendered markdown preview the single interactive review surface — a clean (zero-annotation) editor, an annotations on/off toggle, one combined per-author + strikethrough render, and ✓/✗ accept/reject of Claude's pending F4 proposals from inside the preview. + +**Architecture:** Mostly assembly of shipped features. F3 attribution (`spansFor`), F4 propose/accept (`acceptById`/`rejectById`), and the F6 baseline (`DiffViewController`) stay as **data layers**; their in-editor visuals are removed. One new pure, vscode-free function `renderReview(baselineText, currentText, authorSpans, proposals)` overlays the F7 block/word diff (author-colored via the salvaged F9 PUA-sentinel technique, struck deletions) **and** F4 pending proposals (blue blocks with ✓/✗). The `TrackChangesPreviewController` collapses F9's two modes into one `"on"|"off"` toggle, gains a `ProposalController` dependency, and routes ✓/✗ webview messages through the F4 seam. The webview never mutates the document (INV-20/21/34). + +**Tech Stack:** TypeScript, VS Code extension API, `markdown-it` + `diff` (jsdiff) + `mermaid` (webview-only), esbuild (webview bundle), vitest (vscode-free unit), `@vscode/test-electron` (host E2E). + +**Spec:** `specs/coauthoring-interactive-review.md` (F10, `#29`). Parent invariants INV-1..31 carry over except where F10 supersedes (F10 adds INV-32/33/34; **reverses F9 INV-26**). + +--- + +## File Structure + +**Modify:** +- `src/attributionController.ts` — `render()` stops applying editor decorations (keep `spansFor`); retire decoration types. +- `src/proposalController.ts` — stop creating in-editor comment threads + pending decorations; refactor bookkeeping to plain maps; add `listProposals()` + `onDidChangeProposals`. +- `src/trackChangesModel.ts` — add `renderReview` + `renderPlain` + `colorByAuthor` + `ProposalView`; remove public `renderAuthorship`. +- `src/trackChangesPreview.ts` — collapse mode to `"on"|"off"`; take `ProposalController`; subscribe to `onDidChangeProposals`; new render path; inbound `accept`/`reject`/`setMode`; own a status-bar item. +- `src/extension.ts` — reorder construction (proposals before preview); inject `ProposalController` into the preview. +- `media/preview.ts` — replace segmented control with on/off switch; add ✓/✗ click → `postMessage`. +- `media/preview.css` — proposal-block + ✓/✗ button styles; on/off switch style. +- `package.json` — hide `toggleDiffView`/`ctrl+alt+d`, `toggleAttribution`, in-editor `acceptProposal`/`rejectProposal` (`when:false`); retitle `showTrackChangesPreview`. + +**Create:** +- `docs/MANUAL-SMOKE-F10.md` — the live webview smoke checklist. + +**Test:** +- `test/trackChangesModel.test.ts` — extend (vitest) for `renderReview`/`renderPlain`/`colorByAuthor`. +- `test/e2e/suite/trackChangesPreview.test.ts` and/or a new `f10Review.test.ts` — host E2E. + +--- + +## Conventions for this plan + +- **Unit tests** (the pure `trackChangesModel` engine) are strict TDD: failing test first, run it, minimal impl, run green, commit. Command: `npm test` (vitest) — to run one file: `npx vitest run test/trackChangesModel.test.ts`. +- **vscode-layer changes** (controllers, webview, package.json) cannot be unit-tested vscode-free; they are verified by **host E2E** (`npm run test:e2e`) and the manual smoke. For those tasks the "test" step is the E2E assertion added in SLICE-4 plus a `npm run build` typecheck. +- Build/typecheck after vscode-layer edits: `npm run build` (esbuild + bundles `media/preview.ts`). E2E precompiles via `npm run pretest:e2e`. +- Commit after each task. + +--- + +## SLICE-1 — Clean editor (INV-32) + +Strip F3 attribution decorations and F4 in-editor proposal threads/decorations; hide the F6 two-pane diff command/keybinding and the attribution toggle. Keep `spansFor`, accept/reject logic, and the baseline store. *Mostly deletion.* + +### Task 1: Strip F3 attribution editor decorations + +**Files:** +- Modify: `src/attributionController.ts:62-63` (decoration types), `:354-373` (`render()`), `:82` (dispose list) + +- [ ] **Step 1: Read the current `render()` and decoration setup.** Confirm `render()` computes `agentRanges`/`humanRanges` and calls `editor.setDecorations(this.agentType, …)` / `(this.humanType, …)` at lines 366-367, and that `spansFor` (≈line 410) is independent of these. + +- [ ] **Step 2: Remove the editor-painting side effect from `render()`.** Edit `render()` so it no longer calls `setDecorations` and no longer builds `agentRanges`/`humanRanges`. F10 keeps `render()` as a method (callers exist) but it becomes a no-op for decorations — leave any non-decoration bookkeeping intact. Concretely, delete the two `editor.setDecorations(...)` calls (lines 366-367) and the range-building that feeds only them. + +- [ ] **Step 3: Retire the decoration types.** Delete the `agentType`/`humanType` `createTextEditorDecorationType(...)` fields (lines 62-63), the `AGENT_DECO`/`HUMAN_DECO` constants (≈lines 47-56), and remove `this.agentType, this.humanType` from the dispose push at line 82. Keep `this.statusItem, this.output, this.applyEmitter` in the dispose list. + +- [ ] **Step 4: Typecheck.** + +Run: `npm run build` +Expected: no TypeScript errors; no remaining references to `agentType`/`humanType`/`AGENT_DECO`/`HUMAN_DECO`. + +- [ ] **Step 5: Confirm `spansFor` is untouched.** Grep that `spansFor(` still exists and returns `AuthorSpan[]`. + +Run: `grep -n 'spansFor' src/attributionController.ts` +Expected: the method signature is present and unchanged. + +- [ ] **Step 6: Commit.** + +```bash +git add src/attributionController.ts +git commit -m "F10 SLICE-1: strip F3 attribution editor decorations (keep spansFor) (#29)" +``` + +### Task 2: Strip F4 in-editor proposal threads + decorations; refactor bookkeeping + +The editor must carry **no** F4 visuals. Remove the `CommentController`, the per-proposal comment threads, and the amber `pendingType` decoration — but keep resolving each proposal's anchor into `state.live`/`state.unresolved` so `getRendered` (existing tests) and SLICE-3's `listProposals` still work. The in-editor `acceptThread`/`rejectThread` paths become dead and are removed; `acceptById`/`rejectById` (the public seams) are kept. + +**Files:** +- Modify: `src/proposalController.ts` — `DocState` (`:32-41`), constructor (`:49-71`), `renderAll` (`:187-210`), `renderProposal` (`:243-274`), `renderDecorations` (`:276-285`), `byThread`/`acceptThread`/`rejectThread` (`:137-144`, `:304-314`), `getRendered` (`:323-339`). + +- [ ] **Step 1: Drop the CommentController + pending decoration + thread bookkeeping.** + - Remove `private readonly controller = vscode.comments.createCommentController(...)` (≈line 64) and `private readonly pendingType = ...createTextEditorDecorationType(PENDING_DECO)` (line 53) and the `PENDING_DECO` constant (lines 43-47). + - Remove `this.controller` and `this.pendingType` from the dispose push (line 65) — keep `this.statusItem`. + - Remove the `acceptProposal`/`rejectProposal` command registrations (lines 67-68) — these are the in-editor thread-menu commands; the seams move to the preview in SLICE-3. + - In `DocState` remove `vsThreads: Map` (line 36); keep `live` and `unresolved`. + +- [ ] **Step 2: Replace `renderProposal` (thread+UI) with `recordProposal` (bookkeeping only).** New method records resolved offsets without any vscode UI: + +```ts +/** Record a proposal's resolved offsets (no editor UI — F10 INV-32). */ +private recordProposal(proposal: Proposal, offsets: OffsetRange, pending: boolean): void { + this.docs.get(this.keyOf2(proposal))?.live; // placeholder removed below +} +``` + + …actually keep it simple and stateful via the passed `state`: + +```ts +private recordProposal(state: DocState, proposal: Proposal, offsets: OffsetRange, pending: boolean): void { + state.live.set(proposal.id, offsets); + if (!pending) state.unresolved.add(proposal.id); +} +``` + +- [ ] **Step 3: Rewrite `renderAll` to use `recordProposal` and drop thread/decoration disposal.** Keep the anchor resolve-or-flag loop; remove `for (const vsThread of state.vsThreads.values()) vsThread.dispose();` and `state.vsThreads.clear();` and the `this.renderDecorations(...)` call: + +```ts +renderAll(document: vscode.TextDocument): void { + if (!this.isTracked(document)) return; + const docPath = this.keyOf(document); + const state = this.ensureState(document); + state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); + state.live.clear(); + state.unresolved.clear(); + const text = document.getText(); + for (const proposal of state.artifact.proposals) { + const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; + const resolved = fp ? resolve(text, fp) : "orphaned"; + if (resolved === "orphaned") { + const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0; + const off = document.offsetAt(new vscode.Position(line, 0)); + this.recordProposal(state, proposal, { start: off, end: off }, false); + } else { + this.recordProposal(state, proposal, resolved, true); + } + } + this.renderStatus(state); +} +``` + +- [ ] **Step 4: Remove `renderDecorations` and the thread-following branch in `onDidChange`.** Delete `renderDecorations` (lines 276-285). In `onDidChange` (lines 222-239) keep the `state.live` shift loop but remove the `vsThread.range = ...` update and the trailing `this.renderDecorations(...)` call: + +```ts +private onDidChange(e: vscode.TextDocumentChangeEvent): void { + const state = this.docs.get(this.keyOf(e.document)); + if (!state || state.live.size === 0) return; + for (const change of e.contentChanges) { + const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length }; + for (const [id, range] of state.live) state.live.set(id, shift(range, edit)); + } +} +``` + +- [ ] **Step 5: Remove `acceptThread`/`rejectThread`/`byThread`.** Delete those three methods (lines 137-144, 304-314). `acceptById`/`rejectById`/`byId` stay. `accept`/`reject` keep their `renderAll(document)` re-render calls (now thread-free). + +- [ ] **Step 6: Repoint `getRendered` at `live`/`unresolved`.** It read from `vsThreads`; read from `live` instead: + +```ts +getRendered(docPath: string): RenderedProposal[] { + const state = this.docs.get(docPath); + if (!state) return []; + const out: RenderedProposal[] = []; + for (const [id, off] of state.live) { + const p = state.artifact.proposals.find((x) => x.id === id)!; + out.push({ + id, + pending: !state.unresolved.has(id), + canReply: false, + turnId: p.turnId, + range: { start: off.start, end: off.end }, + }); + } + return out; +} +``` + +- [ ] **Step 7: Typecheck.** + +Run: `npm run build` +Expected: no errors; no remaining references to `vsThreads`, `pendingType`, `PENDING_DECO`, `this.controller`, `acceptThread`, `rejectThread`, `byThread`, `renderDecorations`, `renderProposal`. + +- [ ] **Step 8: Run the existing F4 unit tests (proposalModel — vscode-free) to confirm no regression.** + +Run: `npx vitest run test/proposalModel.test.ts` +Expected: PASS (proposalModel is untouched; this is a guard). + +- [ ] **Step 9: Commit.** + +```bash +git add src/proposalController.ts +git commit -m "F10 SLICE-1: remove F4 in-editor proposal threads/decorations; bookkeeping via live/unresolved (#29)" +``` + +> NOTE: the existing host E2E `test/e2e/suite/proposals.test.ts` may assert in-editor thread/`getRendered` behavior. Do **not** fix it here — SLICE-4 updates the E2E suite. If `npm run test:e2e` is run before SLICE-4, expect known proposal-thread failures. + +### Task 3: Hide F6 two-pane diff + attribution toggle in package.json + +**Files:** +- Modify: `package.json` — keybindings (`:154-165`), `contributes.menus.commandPalette` (`:93-152`), command titles (`:48-91`) + +- [ ] **Step 1: Hide the `ctrl+alt+d` keybinding.** In `contributes.keybindings`, set the `toggleDiffView` entry's `when` to `false`: + +```json +{ "command": "cowriting.toggleDiffView", "key": "ctrl+alt+d", "when": "false" } +``` + +- [ ] **Step 2: Hide `toggleDiffView`, `pinDiffBaseline`, and `toggleAttribution` from the command palette.** In `contributes.menus.commandPalette`, add (or set) `when: "false"` entries: + +```json +{ "command": "cowriting.toggleDiffView", "when": "false" }, +{ "command": "cowriting.pinDiffBaseline", "when": "false" }, +{ "command": "cowriting.toggleAttribution", "when": "false" } +``` + +- [ ] **Step 3: Confirm the in-editor `acceptProposal`/`rejectProposal` palette entries are already `when:false`** (lines 104-110) and **remove their `editor/context`/`comments/commentThread` menu contributions** that referenced the now-deleted comment controller (the `commentController == cowriting.proposals` menu items, lines ≈143-151), since SLICE-1 deleted that controller. + +- [ ] **Step 4: Retitle `showTrackChangesPreview` and rebind to the review verb.** Set its title to `"Cowriting: Open Review Preview"` (line 89) and keep `ctrl+alt+r` / `when: editorLangId == markdown` (lines 161-164). + +- [ ] **Step 5: Validate JSON + typecheck.** + +Run: `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('ok')" && npm run build` +Expected: `ok` then a clean build. + +- [ ] **Step 6: Commit.** + +```bash +git add package.json +git commit -m "F10 SLICE-1: hide F6 diff command/keybinding + attribution toggle; retitle preview to 'Open Review Preview' (#29)" +``` + +--- + +## SLICE-2 — Combined render engine (pure, vscode-free; INV-33) + +Add `renderReview` (the on-state) + `renderPlain` (the off-state) to `trackChangesModel.ts`, extracting the F9 PUA-sentinel coloring into a reusable `colorByAuthor`, and removing the now-superseded public `renderAuthorship`. Strict TDD — this is the pure core. + +### Task 4: Extract `colorByAuthor` from `renderAuthorship`'s inline sentinels + +**Files:** +- Modify: `src/trackChangesModel.ts:326-352` (sentinel helpers), `:360-386` (`renderAuthorship`) +- Test: `test/trackChangesModel.test.ts` + +- [ ] **Step 1: Write a failing unit test for `colorByAuthor`.** It should color a prose block's HTML by author spans (clipped to the block) using the existing sentinel technique. + +```ts +import { colorByAuthor, type AuthorSpan } from "../src/trackChangesModel"; + +test("colorByAuthor wraps human-authored prose in cw-by-human spans", () => { + const raw = "hello world"; + const spans: AuthorSpan[] = [{ start: 0, end: 5, author: "human" }]; + const render = (src: string) => `

${src}

`; + const html = colorByAuthor(raw, 0, spans, render); + expect(html).toContain('hello'); + expect(html).toContain("world"); +}); +``` + +- [ ] **Step 2: Run it — expect failure (`colorByAuthor` not exported).** + +Run: `npx vitest run test/trackChangesModel.test.ts -t colorByAuthor` +Expected: FAIL — `colorByAuthor is not a function` / not exported. + +- [ ] **Step 3: Implement `colorByAuthor` by lifting the sentinel logic.** Export a function that injects sentinels for the block's overlapping spans, runs the provided render, and maps sentinels → spans: + +```ts +/** + * Color one prose block's HTML by F3 author spans (PUA-sentinel technique, + * salvaged from F9 renderAuthorship). `blockStart` is the block's char offset in + * the source so spans map correctly. Pure; deterministic. + */ +export function colorByAuthor( + raw: string, + blockStart: number, + spans: AuthorSpan[], + render: (src: string) => string, +): string { + const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length); + const injected = injectSentinels(raw, blockStart, overlapping); + return sentinelsToSpans(render(injected)); +} +``` + + Keep `injectSentinels`, `sentinelsToSpans`, `SENT`, `isCloseSentinel`, `authorBadge` as the private helpers they already are. + +- [ ] **Step 4: Run the test — expect PASS.** + +Run: `npx vitest run test/trackChangesModel.test.ts -t colorByAuthor` +Expected: PASS. + +- [ ] **Step 5: Refactor `renderAuthorship` to call `colorByAuthor` (no behavior change yet — it's removed in Task 7).** In its prose branch replace the inline `sentinelsToSpans(safe(injectSentinels(...)))` with `colorByAuthor(b.raw, b.start, overlapping, safe)`. + +- [ ] **Step 6: Run the full model suite — expect PASS (no regression).** + +Run: `npx vitest run test/trackChangesModel.test.ts` +Expected: PASS (all existing renderAuthorship/renderTrackChanges cases green). + +- [ ] **Step 7: Commit.** + +```bash +git add src/trackChangesModel.ts test/trackChangesModel.test.ts +git commit -m "F10 SLICE-2: extract colorByAuthor from renderAuthorship sentinels (#29)" +``` + +### Task 5: Add `renderPlain` (the off-state) + +**Files:** +- Modify: `src/trackChangesModel.ts` +- Test: `test/trackChangesModel.test.ts` + +- [ ] **Step 1: Write a failing test.** + +```ts +import { renderPlain } from "../src/trackChangesModel"; + +test("renderPlain renders current buffer as plain markdown (no marks)", () => { + const html = renderPlain("# Title\n\nhello"); + expect(html).toContain("

Title

"); + expect(html).toContain("hello"); + expect(html).not.toContain("cw-"); +}); +``` + +- [ ] **Step 2: Run it — expect FAIL (not exported).** + +Run: `npx vitest run test/trackChangesModel.test.ts -t renderPlain` +Expected: FAIL. + +- [ ] **Step 3: Implement.** + +```ts +/** Off-state body: the current buffer as plain markdown, no annotations (INV-33). */ +export function renderPlain(currentText: string, opts: RenderOptions = {}): string { + const render = opts.render ?? defaultRender; + try { + return render(currentText); + } catch (err) { + return chip(err instanceof Error ? err.message : String(err)); + } +} +``` + +- [ ] **Step 4: Run — expect PASS.** + +Run: `npx vitest run test/trackChangesModel.test.ts -t renderPlain` +Expected: PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add src/trackChangesModel.ts test/trackChangesModel.test.ts +git commit -m "F10 SLICE-2: add renderPlain off-state (#29)" +``` + +### Task 6: Add `ProposalView` + `renderReview` (the on-state) + +`renderReview` overlays two axes in one pass: (1) the F7 baseline block/word diff, with added/changed prose author-colored via `colorByAuthor` and deletions struck; (2) each pending proposal injected at its resolved anchor as a blue `cw-proposal` block carrying `data-proposal-id` and a ✓/✗ placeholder. Unanchored proposals render as trailing blocks (never dropped — INV-34). + +**Files:** +- Modify: `src/trackChangesModel.ts` +- Test: `test/trackChangesModel.test.ts` + +- [ ] **Step 1: Add the `ProposalView` type.** (Resolved offsets are computed by the controller; the pure engine receives them.) + +```ts +export interface ProposalView { + id: string; + /** resolved offsets in currentText; null when the anchor did not resolve. */ + anchorStart: number | null; + anchorEnd: number | null; + /** the text the proposal would replace (fp.text), for the struck "before". */ + replaced: string; + /** the proposed replacement text. */ + replacement: string; +} +``` + +- [ ] **Step 2: Write failing tests covering the on-state contract.** + +```ts +import { renderReview, type ProposalView, type AuthorSpan } from "../src/trackChangesModel"; + +test("renderReview: human addition since baseline renders green ", () => { + const html = renderReview("hello", "hello world", + [{ start: 6, end: 11, author: "human" }], []); + expect(html).toMatch(/[^<]*world[^<]*<\/ins>|cw-by-human/); +}); + +test("renderReview: deletion since baseline renders struck cw-del/", () => { + const html = renderReview("hello world", "hello", [], []); + expect(html).toMatch(/|cw-del/); +}); + +test("renderReview: a pending proposal renders a blue block with data-proposal-id and a ✓/✗ action placeholder", () => { + const proposals: ProposalView[] = [ + { id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }, + ]; + const html = renderReview("hello", "hello", [], proposals); + expect(html).toContain('class="cw-proposal"'); + expect(html).toContain('data-proposal-id="p1"'); + expect(html).toContain("cw-actions"); + expect(html).toContain("goodbye"); + expect(html).toMatch(/[^<]*hello[^<]*<\/del>|cw-del/); +}); + +test("renderReview: an unresolved proposal renders as a trailing block (never dropped)", () => { + const proposals: ProposalView[] = [ + { id: "p2", anchorStart: null, anchorEnd: null, replaced: "x", replacement: "y" }, + ]; + const html = renderReview("a", "a", [], proposals); + expect(html).toContain('data-proposal-id="p2"'); + expect(html).toContain("cw-proposal-unanchored"); +}); + +test("renderReview is deterministic (same inputs → identical HTML)", () => { + const a = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []); + const b = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []); + expect(a).toBe(b); +}); + +test("renderReview: an atomic mermaid change is diffed whole (no inner sentinels)", () => { + const base = "```mermaid\nflowchart LR\n A --> B\n```"; + const cur = "```mermaid\nflowchart LR\n A --> C\n```"; + const html = renderReview(base, cur, [], []); + expect(html).toContain("mermaid"); + expect(html).not.toContain("cw-by-"); // fences stay atomic +}); +``` + +- [ ] **Step 3: Run — expect FAIL (renderReview not exported).** + +Run: `npx vitest run test/trackChangesModel.test.ts -t renderReview` +Expected: FAIL. + +- [ ] **Step 4: Implement `renderReview`.** Reuse `diffBlocks` + `renderOp`, but author-color added/changed prose and append proposal blocks. The simplest correct composition: render the diff body via the existing `renderOp` path **augmented** so added/changed prose `` content is author-colored, then inject proposal blocks. Add an internal `renderReviewBlock` that colors prose and a `proposalBlockHtml` emitter: + +```ts +function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string { + const safe = (src: string): string => { + try { return render(src); } catch (err) { return chip(err instanceof Error ? err.message : String(err)); } + }; + const unanchored = p.anchorStart === null ? " cw-proposal-unanchored" : ""; + const before = p.replaced ? `${safe(p.replaced)}` : ""; + const after = `${safe(p.replacement)}`; + const actions = + `` + + `` + + `` + + ``; + return `
${actions}${before}${after}
`; +} + +/** + * On-state body (INV-33): the F7 baseline diff — added/changed PROSE author-colored + * via colorByAuthor (F9 sentinels), deletions struck — overlaid with F4 pending + * proposals as blue cw-proposal blocks (✓/✗). One pass, pure, vscode-free. + * Proposals are injected at their resolved anchor's block; unresolved ones append + * as trailing cw-proposal-unanchored blocks (never dropped — INV-34). + */ +export function renderReview( + baselineText: string, + currentText: string, + authorSpans: AuthorSpan[], + proposals: ProposalView[], + opts: RenderOptions = {}, +): string { + const render = opts.render ?? defaultRender; + // 1) the diff body, with prose author-coloring layered onto current-side blocks. + const ranges = splitBlocksWithRanges(currentText); + const ops = diffBlocks(baselineText, currentText); + // Map current-side blocks (added/unchanged/changed) to their source ranges for coloring. + const colored = (raw: string): string => { + const blk = ranges.find((r) => r.raw === raw); + if (!blk) return render(raw); + return colorByAuthor(raw, blk.start, authorSpans, render); + }; + const bodyParts = ops.map((op) => renderReviewOp(op, render, colored)); + // 2) proposal blocks. Resolved proposals are appended after the block containing + // their anchor; for v1 simplicity (and determinism) append all proposals in id + // order, anchored ones first, then unanchored — each carries its own marker. + const anchored = proposals.filter((p) => p.anchorStart !== null); + const unanchored = proposals.filter((p) => p.anchorStart === null); + const proposalParts = [...anchored, ...unanchored].map((p) => proposalBlockHtml(p, render)); + return [...bodyParts, ...proposalParts].join("\n"); +} +``` + + Add `renderReviewOp` — a thin wrapper over the existing `renderOp` that swaps the prose renderer for the author-coloring one on `added`/`changed`(non-atomic)/`unchanged` ops: + +```ts +function renderReviewOp( + op: BlockOp, + render: (src: string) => string, + colored: (raw: string) => string, +): string { + // Atomic fences and deletions: identical to renderOp (no author sentinels). + if (op.kind === "removed") return renderOp(op, render); + if (op.kind === "changed" && op.atomic) return renderOp(op, render); + // Prose added/unchanged: author-color the block; changed prose: keep / + // word merge (deletions struck), then we accept that word-merge is not per-author + // colored in v1 (covered by §6.7 "deletion coloring = neutral"). + if (op.kind === "changed") return renderOp(op, render); // word-merged / + // unchanged / added prose → author-colored. + return `
${colored(op.block.raw)}
`; +} +``` + + > Design note: per spec §6.7, deletion coloring is neutral and added-prose author-coloring is the primary signal; changed-prose keeps the F7 word-merge. This keeps the engine deterministic and the tests above green. If a later refinement wants author-colored `` inside changed prose, it extends `renderReviewOp`. + +- [ ] **Step 5: Run the renderReview tests — expect PASS.** + +Run: `npx vitest run test/trackChangesModel.test.ts -t renderReview` +Expected: PASS (all six). + +- [ ] **Step 6: Run the full model suite.** + +Run: `npx vitest run test/trackChangesModel.test.ts` +Expected: PASS. + +- [ ] **Step 7: Commit.** + +```bash +git add src/trackChangesModel.ts test/trackChangesModel.test.ts +git commit -m "F10 SLICE-2: add ProposalView + renderReview combined on-state render (#29)" +``` + +### Task 7: Remove the superseded public `renderAuthorship` + +**Files:** +- Modify: `src/trackChangesModel.ts` (remove `renderAuthorship`), `src/trackChangesPreview.ts` (stops importing it — done in SLICE-3, so here just remove the export and fix the model) +- Test: `test/trackChangesModel.test.ts` (drop/replace renderAuthorship-only cases salvaged into colorByAuthor) + +- [ ] **Step 1: Delete the exported `renderAuthorship` function** (lines 360-386). Keep `colorByAuthor`, `injectSentinels`, `sentinelsToSpans`, `authorBadge`, `SENT`. + +- [ ] **Step 2: Remove or repoint any test that imports `renderAuthorship`.** Any remaining authorship-only assertions are now covered by the `colorByAuthor` test (Task 4); delete the obsolete `renderAuthorship` test cases. + +- [ ] **Step 3: Typecheck — expect a known error in `trackChangesPreview.ts`** (it still imports `renderAuthorship`). That import is removed in SLICE-3 Task 9; note it and proceed (do not patch the preview here beyond removing the dangling import if convenient). + +Run: `npm run build` +Expected: error only at `trackChangesPreview.ts` `renderAuthorship` import → resolved in Task 9. (If you prefer a clean build per-task, remove the import + authorship branch now as a head-start on Task 9.) + +- [ ] **Step 4: Run the model suite — expect PASS.** + +Run: `npx vitest run test/trackChangesModel.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add src/trackChangesModel.ts test/trackChangesModel.test.ts +git commit -m "F10 SLICE-2: remove superseded public renderAuthorship (salvaged into colorByAuthor) (#29)" +``` + +--- + +## SLICE-3 — Interactive controller + webview (INV-34) + +Add `ProposalController.listProposals` + `onDidChangeProposals`; wire `ProposalController` into the preview; collapse mode to `"on"|"off"`; route ✓/✗/setMode messages; rebuild the webview asset; status-bar indicator. + +### Task 8: `ProposalController.listProposals` + `onDidChangeProposals` + +**Files:** +- Modify: `src/proposalController.ts` + +- [ ] **Step 1: Add the change emitter + event.** Near the other fields: + +```ts +private readonly onDidChangeProposalsEmitter = new vscode.EventEmitter<{ uri: string }>(); +/** Fires on propose / accept / reject / external sidecar change (F10). */ +readonly onDidChangeProposals = this.onDidChangeProposalsEmitter.event; +``` + + Push it into `disposables` and fire it: at the end of `propose` (after `renderAll`), `accept` (after `removeProposal`+`renderAll`), `reject` (after `renderAll`), and `handleExternalSidecarChange`/`renderAll`. Fire with the document's URI string: + +```ts +private fireChanged(document: vscode.TextDocument): void { + this.onDidChangeProposalsEmitter.fire({ uri: document.uri.toString() }); +} +``` + + Call `this.fireChanged(document)` at the end of `renderAll(document)` (it is the common funnel for propose/accept/reject/external change). + +- [ ] **Step 2: Add `listProposals`.** Return resolved views for the preview's render. Resolve each proposal against the *current* document text (same as `renderAll`), exposing `fp.text` as `replaced`: + +```ts +import type { ProposalView } from "./trackChangesModel"; + +/** Resolved proposal views for the F10 preview (anchorStart=null when unresolved). */ +listProposals(document: vscode.TextDocument): ProposalView[] { + const docPath = this.keyOf(document); + const artifact = this.store.load(docPath) ?? emptyArtifact(docPath); + const text = document.getText(); + return artifact.proposals.map((p) => { + const fp = artifact.anchors[p.anchorId]?.fingerprint; + const resolved = fp ? resolve(text, fp) : "orphaned"; + return { + id: p.id, + anchorStart: resolved === "orphaned" ? null : resolved.start, + anchorEnd: resolved === "orphaned" ? null : resolved.end, + replaced: fp?.text ?? "", + replacement: p.replacement, + }; + }); +} +``` + +- [ ] **Step 3: Typecheck.** + +Run: `npm run build` +Expected: clean (the `ProposalView` import resolves against Task 6's export). + +- [ ] **Step 4: Commit.** + +```bash +git add src/proposalController.ts +git commit -m "F10 SLICE-3: ProposalController.listProposals + onDidChangeProposals (#29)" +``` + +### Task 9: Collapse preview mode to on/off, take `ProposalController`, route messages + +**Files:** +- Modify: `src/trackChangesPreview.ts` (constructor, mode map, `refresh`, message handler, shell HTML, test seams, imports) + +- [ ] **Step 1: Update imports + mode type.** Replace the model import with the F10 surface and switch the mode union: + +```ts +import { renderReview, renderPlain, diffBlocks, type BlockOp } from "./trackChangesModel"; +import type { ProposalController } from "./proposalController"; +``` + +```ts +/** F10: per-panel annotations toggle — on (combined render) or off (plain). */ +private readonly mode = new Map(); +``` + +- [ ] **Step 2: Add the `ProposalController` constructor dependency + subscribe to its change event.** + +```ts +constructor( + private readonly diffView: DiffViewController, + private readonly extensionUri: vscode.Uri, + private readonly attribution: AttributionController, + private readonly proposals: ProposalController, +) { + this.disposables.push( + vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () => + this.show(vscode.window.activeTextEditor?.document), + ), + vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)), + this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)), + this.proposals.onDidChangeProposals(({ uri }) => { this.refreshByUri(uri); this.updateStatus(uri); }), + ); +} +``` + +- [ ] **Step 3: Replace the inbound message handler** (the `setMode` block) to also handle accept/reject: + +```ts +panel.webview.onDidReceiveMessage( + (m: { type?: string; mode?: "on" | "off"; proposalId?: string }) => { + if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) { + this.mode.set(key, m.mode); + this.refresh(document); + } else if (m?.type === "accept" && m.proposalId) { + void this.proposals + .acceptById(this.proposalKey(document), m.proposalId) + .then(() => this.refresh(document)); + } else if (m?.type === "reject" && m.proposalId) { + this.proposals.rejectById(this.proposalKey(document), m.proposalId); + this.refresh(document); + } + }, + null, + this.disposables, +); +``` + + Add a `proposalKey(document)` helper that returns the same key `ProposalController` uses (`this.store.keyOf(docIdentity(document))`). Since the preview doesn't hold the router, expose a public `keyFor(document): string` on `ProposalController` and call it: + + In `proposalController.ts`: +```ts +/** The doc key F4 uses (F8 routing) — exposed for F10's preview. */ +keyFor(document: vscode.TextDocument): string { return this.keyOf(document); } +``` + In the preview, `private proposalKey(d: vscode.TextDocument) { return this.proposals.keyFor(d); }`. + +- [ ] **Step 4: Rewrite `refresh` for on/off.** Replace the `"authorship"`/`"changes"` branches with one combined `renderReview` (on) / `renderPlain` (off): + +```ts +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) ?? "on"; + const current = document.getText(); + const baseline = this.diffView.getBaseline(key); + const baselineText = baseline?.text ?? current; // no baseline → no change-marks + const ops = diffBlocks(baselineText, current); + this.lastModel.set(key, ops); + if (mode === "off") { + void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current) }); + return; + } + const spans = this.attribution.spansFor(document); + const proposals = this.proposals.listProposals(document); + const summary = { + added: ops.filter((o) => o.kind === "added").length, + removed: ops.filter((o) => o.kind === "removed").length, + proposals: proposals.length, + }; + void panel.webview.postMessage({ + type: "render", + mode, + html: renderReview(baselineText, current, spans, proposals), + epoch: this.epochLabel(baseline), + summary, + }); +} +``` + +- [ ] **Step 5: Update the shell HTML header** — replace the segmented `[Track changes | Authorship]` with an on/off switch: + +```ts +
+ + Review + + +
+``` + +- [ ] **Step 6: Update the test seams** for the new mode type: + +```ts +getMode(uriString: string): "on" | "off" { return this.mode.get(uriString) ?? "on"; } +setMode(uriString: string, mode: "on" | "off"): void { + this.mode.set(uriString, mode); + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); + if (doc) this.refresh(doc); +} +/** F10 test seam: the on-state review HTML the panel would post. */ +renderHtmlFor(uriString: string): string { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); + if (!doc) return ""; + const current = doc.getText(); + const baseline = this.diffView.getBaseline(uriString); + return renderReview(baseline?.text ?? current, current, this.attribution.spansFor(doc), this.proposals.listProposals(doc)); +} +``` + +- [ ] **Step 7: Add the status-bar item + `updateStatus`** (PUC-6) — see Task 10 (implement them here or in Task 10; cross-reference). For now stub `private updateStatus(_uri: string): void {}` so this task typechecks, and fill it in Task 10. + +- [ ] **Step 8: Typecheck.** + +Run: `npm run build` +Expected: clean — no `renderAuthorship`/`renderTrackChanges` references remain in the preview. + +- [ ] **Step 9: Commit.** + +```bash +git add src/trackChangesPreview.ts src/proposalController.ts +git commit -m "F10 SLICE-3: preview takes ProposalController; on/off mode; renderReview path; accept/reject routing (#29)" +``` + +### Task 10: Status-bar indicator (PUC-6) + +A status-bar item shows the pending-proposal count when **no preview panel is open** for any doc with proposals; clicking it runs `cowriting.showTrackChangesPreview`. Hidden when count is 0 or a panel is open. + +**Files:** +- Modify: `src/trackChangesPreview.ts` + +- [ ] **Step 1: Create the status-bar item in the constructor.** + +```ts +private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88); +``` + In the constructor body: `this.statusItem.command = "cowriting.showTrackChangesPreview"; this.disposables.push(this.statusItem);` + +- [ ] **Step 2: Implement `updateStatus`.** Count pending proposals for the doc whose proposals changed; show the item only if that doc has no open panel: + +```ts +private updateStatus(uri: string): void { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); + if (!doc) { this.statusItem.hide(); return; } + const n = this.proposals.listProposals(doc).length; + if (n === 0 || this.panels.has(uri)) { this.statusItem.hide(); return; } + this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`; + this.statusItem.tooltip = "Cowriting: open the review preview to accept/reject Claude's proposals"; + this.statusItem.show(); +} +``` + +- [ ] **Step 3: Hide the status item when a panel opens.** In `show()` after `this.panels.set(key, panel)`, call `this.statusItem.hide()`. In `onDidDispose`, call `this.updateStatus(key)` so it reappears if proposals remain. + +- [ ] **Step 4: Dispose the status item.** Already pushed to `disposables`; confirm `dispose()` clears it. + +- [ ] **Step 5: Typecheck.** + +Run: `npm run build` +Expected: clean. + +- [ ] **Step 6: Commit.** + +```bash +git add src/trackChangesPreview.ts +git commit -m "F10 SLICE-3: status-bar proposal indicator (PUC-6) (#29)" +``` + +### Task 11: Webview asset — on/off switch + ✓/✗ click handler + CSS + +**Files:** +- Modify: `media/preview.ts`, `media/preview.css` + +- [ ] **Step 1: Update the inbound `RenderMessage` type + handler in `media/preview.ts`.** The mode is now `"on"|"off"`; `summary` carries `{added, removed, proposals}`: + +```ts +type RenderMessage = { + type: "render"; + mode: "on" | "off"; + html: string; + epoch?: string; + summary?: { added: number; removed: number; proposals: number }; +}; +``` + + On receive: set `body.innerHTML = msg.html`; reflect the checkbox (`annotationsEl.checked = msg.mode === "on"`); show/hide summary/legend; then `renderMermaid()`. + +- [ ] **Step 2: Wire the on/off checkbox → `postMessage`.** Replace the `.cw-seg` segmented-control wiring: + +```ts +const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null; +annotationsEl?.addEventListener("change", () => { + vscode.postMessage({ type: "setMode", mode: annotationsEl.checked ? "on" : "off" }); +}); +``` + +- [ ] **Step 3: Add the ✓/✗ delegated click handler.** Buttons are inside `.cw-proposal` blocks carrying `data-proposal-id`; read the id + `data-action` and post intent: + +```ts +document.getElementById("cw-body")?.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement)?.closest(".cw-actions button"); + if (!btn) return; + const block = btn.closest(".cw-proposal"); + const id = block?.dataset.proposalId; + const action = btn.dataset.action; + if (id && (action === "accept" || action === "reject")) { + vscode.postMessage({ type: action, proposalId: id }); + } +}); +``` + +- [ ] **Step 4: Add CSS in `media/preview.css`** for the proposal block, ✓/✗ buttons, and on/off toggle, theme-variable-driven: + +```css +.cw-proposal { + position: relative; + border-left: 3px solid var(--vscode-charts-blue, #4daafc); + background: color-mix(in srgb, var(--vscode-charts-blue, #4daafc) 12%, transparent); + padding: 0.4em 0.6em; + margin: 0.4em 0; + border-radius: 3px; +} +.cw-proposal-unanchored { border-left-style: dashed; opacity: 0.85; } +.cw-actions { position: absolute; top: 0.2em; right: 0.4em; display: inline-flex; gap: 0.25em; } +.cw-actions button { + cursor: pointer; border: 1px solid var(--vscode-button-border, transparent); + border-radius: 3px; font-size: 0.9em; line-height: 1; padding: 0.1em 0.35em; + background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); +} +.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; } +.cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; } +#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; } +``` + +- [ ] **Step 5: Build the webview bundle + typecheck.** + +Run: `npm run build` +Expected: clean; `out/media/preview.js` rebuilt. + +- [ ] **Step 6: Commit.** + +```bash +git add media/preview.ts media/preview.css +git commit -m "F10 SLICE-3: webview on/off switch + ✓/✗ click→postMessage + proposal CSS (#29)" +``` + +### Task 12: Wire it together in `extension.ts` + +**Files:** +- Modify: `src/extension.ts:92-105` (reorder: proposals before preview; inject) + +- [ ] **Step 1: Move `ProposalController` construction above `TrackChangesPreviewController`** and pass it in: + +```ts +// --- F4: propose/accept (Feature #12) — constructed before the preview so F10 +// can route ✓/✗ through it --- +const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard); +context.subscriptions.push(proposalController); + +// --- F7/F10: the review preview is the single interactive review surface --- +const trackChangesPreviewController = new TrackChangesPreviewController( + diffViewController, + context.extensionUri, + attributionController, + proposalController, +); +context.subscriptions.push(trackChangesPreviewController); +``` + + Remove the now-duplicate later `proposalController` construction (old line 104). + +- [ ] **Step 2: Typecheck + full unit suite.** + +Run: `npm run build && npm test` +Expected: clean build; vitest green (model + proposalModel + all vscode-free suites). + +- [ ] **Step 3: Commit.** + +```bash +git add src/extension.ts +git commit -m "F10 SLICE-3: wire ProposalController into the review preview (#29)" +``` + +--- + +## SLICE-4 — Tests & docs + +### Task 13: Host E2E for the interactive review flow + +**Files:** +- Modify: `test/e2e/suite/trackChangesPreview.test.ts` (or create `test/e2e/suite/f10Review.test.ts`), `test/e2e/suite/proposals.test.ts` (drop in-editor-thread assertions), `test/e2e/suite/authorship.test.ts` (remove/repoint F9 authorship-mode cases) + +- [ ] **Step 1: Repoint the obsolete F9 authorship E2E.** `authorship.test.ts` exercised `setMode("authorship")` + `renderAuthorship`. Either delete the file or rewrite its cases against `setMode(uri, "on")` and `renderReview` output (author-colored spans present). Confirm the suite index still imports valid files. + +- [ ] **Step 2: Fix `proposals.test.ts`.** Remove assertions about in-editor comment threads / `getRendered.canReply` semantics that no longer hold; keep `acceptById`/`rejectById` behavior assertions (those seams are unchanged). + +- [ ] **Step 3: Add the F10 host-E2E cases** (extend `trackChangesPreview.test.ts`). Use the existing harness patterns (open a markdown fixture, run `cowriting.showTrackChangesPreview`, drive the controller via its test seams and the `applyAgentEdit`/`proposeAgentEdit` commands). Assert: + - open markdown → panel open; `getLastModel` on a fresh baseline shows no change-marks. + - type text → an `added` BlockOp; the review HTML (`renderHtmlFor`) contains a `cw-by-human` span. + - `proposeAgentEdit` seam → `listProposals` returns one view with an id; `renderHtmlFor` contains `data-proposal-id` + `cw-actions`. + - simulate accept: call `proposalController.acceptById(key, id)` → proposal gone from `listProposals`, baseline advanced, the block now ordinary; document text reflects the replacement. + - simulate reject on a second proposal → gone from `listProposals`; document text unchanged. + - **clean editor:** assert no attribution decoration types are applied — e.g. assert `vscode.window.activeTextEditor` has no cowriting decorations (or that `AttributionController.render` no longer creates them; a structural assertion that the decoration-type fields were removed). + - `toggleDiffView` is hidden: assert its keybinding `when` is `false` (read from `package.json`) or that invoking it is a no-op user-facing. + - non-markdown doc → `show` warns, no panel (`isOpen` false). + - status-bar: after a propose with no panel open for that doc, the controller's status path reports a pending count (assert via a small seam if needed, e.g. expose `statusText()` for tests). + +- [ ] **Step 4: Add a tiny test seam if needed.** If asserting the status bar is awkward, add `statusText(): string | undefined` returning `this.statusItem.text` when visible. + +- [ ] **Step 5: Run the host E2E suite.** + +Run: `npm run test:e2e` +Expected: PASS (all suites, including the updated proposals/authorship and new F10 cases). + +- [ ] **Step 6: Run the full unit suite too.** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 7: Commit.** + +```bash +git add test +git commit -m "F10 SLICE-4: host E2E for interactive review (open/toggle/propose→accept→reject/clean-editor/hidden-F6/status) (#29)" +``` + +### Task 14: Manual smoke doc + README + +**Files:** +- Create: `docs/MANUAL-SMOKE-F10.md` +- Modify: `README.md` (add an F10 section; note F6 two-pane + F9 authorship-mode are superseded as user surfaces) + +- [ ] **Step 1: Write `docs/MANUAL-SMOKE-F10.md`** following the F7/F9 smoke format. Checklist: open a markdown doc → editor is clean (no tint/threads); edit prose → green `` / struck `` in the preview; ask Claude to edit a selection → a blue proposal block with ✓/✗ appears; click ✓ (lands, mark clears, editor updated) and ✗ on another (block vanishes, doc unchanged); toggle Annotations off (clean render) / on; with no preview open, the status-bar indicator shows the pending count and opens the preview when clicked; verify light/dark/high-contrast theming; verify `git status` shows nothing persisted. + +- [ ] **Step 2: Update `README.md`.** Add an F10 entry to the feature list describing "write left / review right," the annotations on/off toggle, and ✓/✗ in the preview; add a one-line note that F6's two-pane diff and F9's authorship mode are retained as data layers but no longer separate user surfaces; mention `ctrl+alt+r` opens the review preview. + +- [ ] **Step 3: Commit.** + +```bash +git add docs/MANUAL-SMOKE-F10.md README.md +git commit -m "F10 SLICE-4: manual smoke checklist + README F10 section (#29)" +``` + +--- + +## Final verification (before PR) + +- [ ] `npm test` — vitest green (model incl. renderReview/renderPlain/colorByAuthor; proposalModel; all vscode-free suites). +- [ ] `npm run test:e2e` — host E2E green (open/toggle/propose→accept→reject/clean-editor/hidden-F6/status/non-markdown). +- [ ] `npm run build` — clean typecheck + bundles. +- [ ] Manual smoke per `docs/MANUAL-SMOKE-F10.md` performed once (the webview visual + real button clicks the E2E can't cover). +- [ ] `git status` clean (nothing persisted by the preview — INV-20). +- [ ] Open PR to `main`, citing `#29` and `specs/coauthoring-interactive-review.md`; acceptance per spec §7.3. + +--- + +## Self-Review notes (spec coverage) + +- INV-32 (clean editor) → SLICE-1 (Tasks 1-3). INV-33 (combined pure render) → SLICE-2 (Tasks 4-7). INV-34 (✓/✗ via F4 seam) → SLICE-3 (Tasks 8-12). +- PUC-1 clean editor → Task 1/2; PUC-2 toggle → Task 9/11; PUC-3 who-changed-what → Task 6; PUC-4/5 accept/reject → Task 9 (routing) + Task 6 (blocks); PUC-6 status-bar → Task 10; PUC-7 edges (non-markdown warn, no-baseline note, unanchored proposal) → Task 6 (unanchored), Task 9 (no-baseline), `show()` (non-markdown, existing). +- F9 INV-26 reversal (authorship combined with diff; remove segmented control/`renderAuthorship`) → Tasks 7, 9, 13(step1). +- Supersession: F6 two-pane hidden → Task 3; F9 INV-27/28 absorbed into renderReview → Task 6. +- Reconciliation captured: public seams `acceptById`/`rejectById` (Task 9), `Proposal.anchorId` resolved via `resolve()` in `listProposals` (Task 8), new `onDidChangeProposals` (Task 8), `keyFor` exposure for the preview's doc key (Task 9). diff --git a/media/preview.css b/media/preview.css index 0e10047..87aeb9e 100644 --- a/media/preview.css +++ b/media/preview.css @@ -56,14 +56,26 @@ pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); } .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; } +#cw-summary .cw-prop { opacity: 0.85; } + +/* F10 interactive review — annotations toggle + ✓/✗ proposal blocks. */ +#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; } +.cw-proposal { + position: relative; + border-left: 3px solid var(--vscode-charts-blue, #4daafc); + background: color-mix(in srgb, var(--vscode-charts-blue, #4daafc) 12%, transparent); + padding: 0.4em 0.6em; margin: 0.4em 0; border-radius: 3px; +} +.cw-proposal-unanchored { border-left-style: dashed; opacity: 0.85; } +.cw-actions { position: absolute; top: 0.2em; right: 0.4em; display: inline-flex; gap: 0.25em; } +.cw-actions button { + cursor: pointer; border: 1px solid var(--vscode-button-border, transparent); + border-radius: 3px; font-size: 0.9em; line-height: 1; padding: 0.1em 0.35em; + background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); +} +.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; } +.cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; } /* F7.1 (#22) intra-diagram mermaid diff legend. */ .cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; } diff --git a/media/preview.ts b/media/preview.ts index 4492857..d4e58c7 100644 --- a/media/preview.ts +++ b/media/preview.ts @@ -14,11 +14,10 @@ declare function acquireVsCodeApi(): { postMessage(m: unknown): void }; interface RenderMessage { type: "render"; - mode: "changes" | "authorship"; + mode: "on" | "off"; html: string; epoch?: string; - summary?: { added: number; removed: number; changed: number }; - legend?: { claude: boolean; human: boolean }; + summary?: { added: number; removed: number; proposals: number }; } const vscodeApi = acquireVsCodeApi(); @@ -26,13 +25,24 @@ 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")); +const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null; -for (const seg of segs) { - seg.addEventListener("click", () => { - vscodeApi.postMessage({ type: "setMode", mode: seg.dataset.mode }); - }); -} +// F10: the annotations on/off toggle. +annotationsEl?.addEventListener("change", () => { + vscodeApi.postMessage({ type: "setMode", mode: annotationsEl.checked ? "on" : "off" }); +}); + +// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam). +body.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement)?.closest(".cw-actions button"); + if (!btn) return; + const block = btn.closest(".cw-proposal"); + const id = block?.dataset.proposalId; + const action = btn.dataset.action; + if (id && (action === "accept" || action === "reject")) { + vscodeApi.postMessage({ type: action, proposalId: id }); + } +}); function themeFor(): "dark" | "default" { return document.body.classList.contains("vscode-dark") || @@ -59,21 +69,18 @@ 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 ?? ""}`; + const on = msg.mode === "on"; + if (annotationsEl) annotationsEl.checked = on; + // Off-state is a clean preview: hide the review chrome. + header.hidden = !on; + summary.hidden = !on; + legend.hidden = true; + if (on) { + header.textContent = `Review since ${msg.epoch ?? ""}`; summary.innerHTML = - `+${(msg.summary?.added ?? 0) + (msg.summary?.changed ?? 0)} ` + - `−${(msg.summary?.removed ?? 0) + (msg.summary?.changed ?? 0)}`; + `+${msg.summary?.added ?? 0} ` + + `−${msg.summary?.removed ?? 0} ` + + `${msg.summary?.proposals ?? 0} proposal${(msg.summary?.proposals ?? 0) === 1 ? "" : "s"}`; } void renderMermaid(); }); diff --git a/package.json b/package.json index 417a205..2a72d27 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,6 @@ "title": "Reopen Thread", "category": "Cowriting" }, - { - "command": "cowriting.toggleAttribution", - "title": "Toggle Attribution", - "category": "Cowriting" - }, { "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", @@ -86,7 +81,7 @@ }, { "command": "cowriting.showTrackChangesPreview", - "title": "Cowriting: Open Track-Changes Preview", + "title": "Cowriting: Open Review Preview", "category": "Cowriting" } ], @@ -107,6 +102,14 @@ { "command": "cowriting.rejectProposal", "when": "false" + }, + { + "command": "cowriting.toggleDiffView", + "when": "false" + }, + { + "command": "cowriting.pinDiffBaseline", + "when": "false" } ], "editor/context": [ @@ -138,16 +141,6 @@ "command": "cowriting.reopenThread", "group": "inline", "when": "commentController == cowriting.threads && commentThread =~ /^resolved$/" - }, - { - "command": "cowriting.acceptProposal", - "group": "inline@1", - "when": "commentController == cowriting.proposals && commentThread =~ /^pending$/" - }, - { - "command": "cowriting.rejectProposal", - "group": "inline@2", - "when": "commentController == cowriting.proposals" } ] }, @@ -155,7 +148,7 @@ { "command": "cowriting.toggleDiffView", "key": "ctrl+alt+d", - "when": "editorTextFocus" + "when": "false" }, { "command": "cowriting.showTrackChangesPreview", diff --git a/src/attributionController.ts b/src/attributionController.ts index 8353fe8..1bb13b9 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -2,9 +2,11 @@ * AttributionController — the thin editor-facing layer for F3 (spec §6.2). * Wires the pure AttributionTracker + PendingEditRegistry + Store/Anchorer to * the editor: human typing → human spans, seam edits → agent spans (INV-9), - * decorations (Claude tint / human gutter border / toggle), save-time - * persistence, load/external-change resolve-or-orphan (INV-1/INV-6), and the - * orphan status-bar count. Sidecar self-write suppression and the shared + * save-time persistence, load/external-change resolve-or-orphan + * (INV-1/INV-6), and the orphan status-bar count. The editor carries no + * in-editor attribution decorations — the rendered preview is the single + * review surface (F10/INV-32); spansFor() feeds it the live attribution. + * Sidecar self-write suppression and the shared * FileSystemWatcher live in CoauthorStore / extension.ts (the sidecar is * co-owned with ThreadController). */ @@ -44,26 +46,12 @@ interface DocAttribution { hadAttributions: boolean; } -const AGENT_DECO: vscode.DecorationRenderOptions = { - backgroundColor: "rgba(99, 102, 241, 0.18)", - overviewRulerColor: "rgba(99, 102, 241, 0.8)", - overviewRulerLane: vscode.OverviewRulerLane.Right, -}; -const HUMAN_DECO: vscode.DecorationRenderOptions = { - borderColor: "rgba(16, 185, 129, 0.8)", - borderStyle: "solid", - borderWidth: "0 0 0 2px", -}; - export class AttributionController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; private readonly docs = new Map(); private readonly pending = new PendingEditRegistry(); - private readonly agentType = vscode.window.createTextEditorDecorationType(AGENT_DECO); - private readonly humanType = vscode.window.createTextEditorDecorationType(HUMAN_DECO); private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 90); private readonly output = vscode.window.createOutputChannel("Cowriting Attribution"); - private visible = true; /** * F6 (§6.2/§6.4): the single machine-landing signal. Fired after a real @@ -79,9 +67,8 @@ export class AttributionController implements vscode.Disposable { private readonly rootDir: string | undefined, private readonly guard: VersionGuard, ) { - this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output, this.applyEmitter); + this.disposables.push(this.statusItem, this.output, this.applyEmitter); this.disposables.push( - vscode.commands.registerCommand("cowriting.toggleAttribution", () => this.toggle()), vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), vscode.window.onDidChangeActiveTextEditor(() => this.renderActive()), @@ -341,11 +328,6 @@ export class AttributionController implements vscode.Disposable { // ---- PUC-5: rendering ---------------------------------------------------------------- - private toggle(): void { - this.visible = !this.visible; - this.renderActive(); - } - private renderActive(): void { const editor = vscode.window.activeTextEditor; if (editor) this.render(editor.document); @@ -354,19 +336,6 @@ export class AttributionController implements vscode.Disposable { private render(document: vscode.TextDocument): void { if (!this.isTracked(document)) return; const s = this.docs.get(this.keyOf(document)); - const spans = this.visible && s ? s.spans : []; - const toRange = (sp: LiveSpan) => - new vscode.Range(document.positionAt(sp.start), document.positionAt(sp.end)); - const agentRanges = spans.filter((x) => x.author.kind === "agent").map(toRange); - const humanRanges = spans.filter((x) => x.author.kind === "human").map(toRange); - // Apply decorations to ALL visible split-editors showing this document, not - // just the first match — each editor pane has its own decoration layer. - for (const editor of vscode.window.visibleTextEditors) { - if (editor.document === document) { - editor.setDecorations(this.agentType, agentRanges); - editor.setDecorations(this.humanType, humanRanges); - } - } if (document === vscode.window.activeTextEditor?.document) { this.renderStatus(s); } @@ -414,10 +383,6 @@ export class AttributionController implements vscode.Disposable { author: s.authorKind === "agent" ? "claude" : "human", })); } - isVisible(): boolean { - return this.visible; - } - dispose(): void { for (const d of this.disposables) d.dispose(); } diff --git a/src/extension.ts b/src/extension.ts index f4a388e..c29b13a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -89,21 +89,23 @@ 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 --- + // --- F4: propose/accept (Feature #12) — constructed before the preview so F10 + // can route ✓/✗ through it --- + const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard); + context.subscriptions.push(proposalController); + + // --- F7/F10: the review preview is the single interactive review surface --- // 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). + // INV-20). Constructed AFTER attribution (reads F3 spans) and proposals (routes + // F4 accept/reject from the webview ✓/✗). const trackChangesPreviewController = new TrackChangesPreviewController( diffViewController, context.extensionUri, attributionController, + proposalController, ); context.subscriptions.push(trackChangesPreviewController); - // --- F4: propose/accept (Feature #12) --- - const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard); - context.subscriptions.push(proposalController); - // --- F6 machine-landing wiring — now for ANY authorable doc --- // The seam's single machine-landing signal advances the F6 baseline (INV-18); // the seam can now fire on out-of-folder files too, so wire it unconditionally. diff --git a/src/proposalController.ts b/src/proposalController.ts index 14ed4a4..5415b71 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -4,7 +4,8 @@ * ingress (INV-10: NEVER mutates the document), persistence at propose time, * resolve-or-flag on load/external change (INV-11 — a proposal's anchor is * immutable for its life: no save-time re-fingerprint, unlike threads), - * rendering (second Comments controller + amber pending-range decoration), + * anchor bookkeeping into state.live/state.unresolved (no in-editor UI — + * F10/INV-32 makes the rendered preview the single review surface), * and the human-only accept/reject gestures (INV-12). Accept drives the seam * (AttributionController.applyAgentEdit, INV-9) so accepted text lands * Claude-attributed with zero new attribution code. @@ -13,10 +14,11 @@ import * as vscode from "vscode"; import { SidecarRouter, docIdentity } from "./sidecarRouter"; import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model"; import { resolve, shift, type OffsetRange } from "./anchorer"; -import { addProposal, proposalBody, removeProposal } from "./proposalModel"; +import { addProposal, removeProposal } from "./proposalModel"; import type { AttributionController } from "./attributionController"; import type { VersionGuard } from "./versionGuard"; import { isAuthorable } from "./workspacePath"; +import type { ProposalView } from "./trackChangesModel"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedProposal { @@ -33,25 +35,19 @@ interface DocState { docPath: string; uri: vscode.Uri; artifact: Artifact; - vsThreads: Map; /** proposal id -> live offset range (within-session optimization, INV-3). */ live: Map; /** proposal ids whose anchor did not resolve at last render (stale/orphaned). */ unresolved: Set; } -const PENDING_DECO: vscode.DecorationRenderOptions = { - backgroundColor: "rgba(245, 158, 11, 0.18)", - overviewRulerColor: "rgba(245, 158, 11, 0.8)", - overviewRulerLane: vscode.OverviewRulerLane.Right, -}; - export class ProposalController implements vscode.Disposable { - private readonly controller: vscode.CommentController; private readonly disposables: vscode.Disposable[] = []; private readonly docs = new Map(); // keyed by docPath - private readonly pendingType = vscode.window.createTextEditorDecorationType(PENDING_DECO); private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 89); + private readonly onDidChangeProposalsEmitter = new vscode.EventEmitter<{ uri: string }>(); + /** Fires on propose / accept / reject / external sidecar change (F10). */ + readonly onDidChangeProposals = this.onDidChangeProposalsEmitter.event; constructor( private readonly store: SidecarRouter, @@ -59,17 +55,17 @@ export class ProposalController implements vscode.Disposable { private readonly rootDir: string | undefined, private readonly guard: VersionGuard, ) { - // No commentingRangeProvider: humans never open proposal threads by hand — - // proposals are born of machine turns only (INV-12 keeps decisions human). - this.controller = vscode.comments.createCommentController("cowriting.proposals", "Claude Proposals"); - this.disposables.push(this.controller, this.pendingType, this.statusItem); + this.disposables.push(this.statusItem); + this.disposables.push(this.onDidChangeProposalsEmitter); this.disposables.push( - vscode.commands.registerCommand("cowriting.acceptProposal", (t: vscode.CommentThread) => this.acceptThread(t)), - vscode.commands.registerCommand("cowriting.rejectProposal", (t: vscode.CommentThread) => this.rejectThread(t)), vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), ); } + private fireChanged(document: vscode.TextDocument): void { + this.onDidChangeProposalsEmitter.fire({ uri: document.uri.toString() }); + } + private isTracked(document: vscode.TextDocument): boolean { return isAuthorable(document.uri.scheme); } @@ -77,6 +73,27 @@ export class ProposalController implements vscode.Disposable { private keyOf(document: vscode.TextDocument): string { return this.store.keyOf(docIdentity(document)); } + /** The doc key F4 uses (F8 routing) — exposed for F10's preview. */ + keyFor(document: vscode.TextDocument): string { + return this.keyOf(document); + } + /** Resolved proposal views for the F10 preview (anchorStart=null when unresolved). */ + listProposals(document: vscode.TextDocument): ProposalView[] { + const docPath = this.keyOf(document); + const artifact = this.store.load(docPath) ?? emptyArtifact(docPath); + const text = document.getText(); + return artifact.proposals.map((p) => { + const fp = artifact.anchors[p.anchorId]?.fingerprint; + const resolved = fp ? resolve(text, fp) : "orphaned"; + return { + id: p.id, + anchorStart: resolved === "orphaned" ? null : resolved.start, + anchorEnd: resolved === "orphaned" ? null : resolved.end, + replaced: fp?.text ?? "", + replacement: p.replacement, + }; + }); + } private ensureState(document: vscode.TextDocument): DocState { const docPath = this.keyOf(document); let state = this.docs.get(docPath); @@ -85,7 +102,6 @@ export class ProposalController implements vscode.Disposable { docPath, uri: document.uri, artifact: this.store.load(docPath) ?? emptyArtifact(docPath), - vsThreads: new Map(), live: new Map(), unresolved: new Set(), }; @@ -134,15 +150,6 @@ export class ProposalController implements vscode.Disposable { return true; } - private async acceptThread(vsThread: vscode.CommentThread): Promise { - const hit = this.byThread(vsThread); - if (hit) await this.accept(hit.state, hit.proposal); - } - private rejectThread(vsThread: vscode.CommentThread): void { - const hit = this.byThread(vsThread); - if (hit) this.reject(hit.state, hit.proposal); - } - private async accept(state: DocState, proposal: Proposal): Promise { if (this.guard.isReadOnly(state.docPath)) return false; const document = this.openDoc(state); @@ -189,8 +196,6 @@ export class ProposalController implements vscode.Disposable { const docPath = this.keyOf(document); const state = this.ensureState(document); state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); - for (const vsThread of state.vsThreads.values()) vsThread.dispose(); - state.vsThreads.clear(); state.live.clear(); state.unresolved.clear(); const text = document.getText(); @@ -200,13 +205,13 @@ export class ProposalController implements vscode.Disposable { if (resolved === "orphaned") { const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0; const off = document.offsetAt(new vscode.Position(line, 0)); - this.renderProposal(document, state, proposal, { start: off, end: off }, false); + this.recordProposal(state, proposal, { start: off, end: off }, false); } else { - this.renderProposal(document, state, proposal, resolved, true); + this.recordProposal(state, proposal, resolved, true); } } - this.renderDecorations(document, state); this.renderStatus(state); + this.fireChanged(document); } /** Shared-watcher entry point (extension.ts): a sidecar changed externally. */ @@ -224,66 +229,23 @@ export class ProposalController implements vscode.Disposable { if (!state || state.live.size === 0) return; for (const change of e.contentChanges) { const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length }; - for (const [id, range] of state.live) { - const next = shift(range, edit); - state.live.set(id, next); - const vsThread = state.vsThreads.get(id); - if (vsThread && !state.unresolved.has(id)) { - vsThread.range = new vscode.Range(e.document.positionAt(next.start), e.document.positionAt(next.end)); - } - } + for (const [id, range] of state.live) state.live.set(id, shift(range, edit)); } - // Live shift keeps the UI following; staleness is judged at decision time - // (accept re-resolves, INV-11) and at the next renderAll. - this.renderDecorations(e.document, state); } // ---- rendering ----------------------------------------------------------------------- - private renderProposal( - document: vscode.TextDocument, - state: DocState, - proposal: Proposal, - offsets: OffsetRange, - pending: boolean, - ): void { - const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; - const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end)); - const vsThread = this.controller.createCommentThread(document.uri, range, [ - { - body: new vscode.MarkdownString(proposalBody(fp?.text ?? "", proposal)), - mode: vscode.CommentMode.Preview, - author: { name: proposal.author.id }, - }, - ]); - // Proposals are decide-only (INV-12): ✓ accept / ✗ reject in the title - // bar. Without this, VS Code renders its default "Reply…" input with no - // submit command wired — a dead end. Discussion belongs in a regular - // coauthoring thread; proposal discussion trails are deferred (spec §1.7). - vsThread.canReply = false; - vsThread.label = pending - ? "Pending proposal" - : "⚠ Stale proposal (target text changed or missing) — accept disabled"; - vsThread.contextValue = pending ? "pending" : "unresolved"; - vsThread.collapsibleState = pending - ? vscode.CommentThreadCollapsibleState.Expanded - : vscode.CommentThreadCollapsibleState.Collapsed; - state.vsThreads.set(proposal.id, vsThread); + /** + * Record a proposal's resolved anchor in the live/unresolved bookkeeping. + * No editor UI (F10/INV-32: the rendered preview is the single review + * surface) — this keeps state.live/state.unresolved populated so the preview + * (SLICE-3) and the getRendered test seam can read it. + */ + private recordProposal(state: DocState, proposal: Proposal, offsets: OffsetRange, pending: boolean): void { state.live.set(proposal.id, offsets); if (!pending) state.unresolved.add(proposal.id); } - private renderDecorations(document: vscode.TextDocument, state: DocState): void { - const ranges: vscode.Range[] = []; - for (const [id, off] of state.live) { - if (state.unresolved.has(id)) continue; - ranges.push(new vscode.Range(document.positionAt(off.start), document.positionAt(off.end))); - } - for (const editor of vscode.window.visibleTextEditors) { - if (editor.document === document) editor.setDecorations(this.pendingType, ranges); - } - } - private renderStatus(state: DocState): void { const n = state.unresolved.size; if (n === 0) { @@ -301,17 +263,6 @@ export class ProposalController implements vscode.Disposable { private openDoc(state: DocState): vscode.TextDocument | undefined { return vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath); } - private byThread(vsThread: vscode.CommentThread): { state: DocState; proposal: Proposal } | undefined { - for (const state of this.docs.values()) { - for (const [id, t] of state.vsThreads) { - if (t === vsThread) { - const proposal = state.artifact.proposals.find((p) => p.id === id); - if (proposal) return { state, proposal }; - } - } - } - return undefined; - } private byId(docPath: string, proposalId: string): { state: DocState; proposal: Proposal } | undefined { const state = this.docs.get(docPath); const proposal = state?.artifact.proposals.find((p) => p.id === proposalId); @@ -324,16 +275,9 @@ export class ProposalController implements vscode.Disposable { const state = this.docs.get(docPath); if (!state) return []; const out: RenderedProposal[] = []; - for (const [id, vsThread] of state.vsThreads) { + for (const [id, off] of state.live) { const p = state.artifact.proposals.find((x) => x.id === id)!; - const off = state.live.get(id)!; - out.push({ - id, - pending: !state.unresolved.has(id), - canReply: vsThread.canReply !== false, - turnId: p.turnId, - range: { start: off.start, end: off.end }, - }); + out.push({ id, pending: !state.unresolved.has(id), canReply: false, turnId: p.turnId, range: { start: off.start, end: off.end } }); } return out; } diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index ffb44bc..1c467b1 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -352,17 +352,43 @@ function sentinelsToSpans(html: string): string { } /** - * 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. + * Color one prose block's HTML by F3 author spans (PUA-sentinel technique, + * salvaged from F9 renderAuthorship). `blockStart` is the block's char offset in + * the source so spans map correctly. Pure; deterministic. */ -export function renderAuthorship( - currentText: string, +export function colorByAuthor( + raw: string, + blockStart: number, spans: AuthorSpan[], - opts: RenderOptions = {}, + render: (src: string) => string, ): string { + const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length); + const injected = injectSentinels(raw, blockStart, overlapping); + return sentinelsToSpans(render(injected)); +} + +/** Off-state body: the current buffer as plain markdown, no annotations (INV-33). */ +export function renderPlain(currentText: string, opts: RenderOptions = {}): string { const render = opts.render ?? defaultRender; + try { + return render(currentText); + } catch (err) { + return chip(err instanceof Error ? err.message : String(err)); + } +} + +export interface ProposalView { + id: string; + /** resolved offsets in currentText; null when the anchor did not resolve. */ + anchorStart: number | null; + anchorEnd: number | null; + /** the text the proposal would replace (fp.text), for the struck "before". */ + replaced: string; + /** the proposed replacement text. */ + replacement: string; +} + +function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string { const safe = (src: string): string => { try { return render(src); @@ -370,19 +396,56 @@ export function renderAuthorship( 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"); + const unanchored = p.anchorStart === null ? " cw-proposal-unanchored" : ""; + const before = p.replaced ? `${safe(p.replaced)}` : ""; + const after = `${safe(p.replacement)}`; + const actions = + `` + + `` + + `` + + ``; + return `
${actions}${before}${after}
`; +} + +function renderReviewOp( + op: BlockOp, + render: (src: string) => string, + colored: (raw: string) => string, +): string { + // removed blocks and any changed block (atomic fences diffed whole; non-atomic prose + // word-merged) render via renderOp — no author sentinels (deletions neutral, spec §6.7). + if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render); + return `
${colored(op.block.raw)}
`; +} + +/** + * On-state body (INV-33): the F7 baseline diff — added/changed PROSE author-colored + * via colorByAuthor (F9 sentinels), deletions struck — overlaid with F4 pending + * proposals as blue cw-proposal blocks (✓/✗). One pass, pure, vscode-free. + * Resolved proposals append after the diff body; unresolved ones append as trailing + * cw-proposal-unanchored blocks (never dropped — INV-34). + */ +export function renderReview( + baselineText: string, + currentText: string, + authorSpans: AuthorSpan[], + proposals: ProposalView[], + opts: RenderOptions = {}, +): string { + const render = opts.render ?? defaultRender; + const ranges = splitBlocksWithRanges(currentText); + const ops = diffBlocks(baselineText, currentText); + let ci = 0; // pointer into ranges; advances for every op with a current-side block + const bodyParts = ops.map((op) => { + const blk = op.kind === "removed" ? undefined : ranges[ci++]; + const colored = (raw: string): string => + blk ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw); + return renderReviewOp(op, render, colored); + }); + const anchored = proposals.filter((p) => p.anchorStart !== null); + const unanchored = proposals.filter((p) => p.anchorStart === null); + const proposalParts = [...anchored, ...unanchored].map((p) => proposalBlockHtml(p, render)); + return [...bodyParts, ...proposalParts].join("\n"); } /** Pure entry point: annotated HTML body for the preview (INV-22). */ diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index c8b2763..433a1d9 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -12,7 +12,8 @@ import { randomBytes } from "node:crypto"; import * as vscode from "vscode"; import type { DiffViewController } from "./diffViewController"; import type { AttributionController } from "./attributionController"; -import { renderTrackChanges, renderAuthorship, diffBlocks, type BlockOp } from "./trackChangesModel"; +import type { ProposalController } from "./proposalController"; +import { renderReview, renderPlain, diffBlocks, type BlockOp } from "./trackChangesModel"; const VIEW_TYPE = "cowriting.trackChangesPreview"; const DEBOUNCE_MS = 150; @@ -22,13 +23,16 @@ 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(); + /** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */ + private readonly mode = new Map(); + /** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */ + private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88); constructor( private readonly diffView: DiffViewController, private readonly extensionUri: vscode.Uri, private readonly attribution: AttributionController, + private readonly proposals: ProposalController, ) { this.disposables.push( vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () => @@ -36,7 +40,13 @@ export class TrackChangesPreviewController implements vscode.Disposable { ), vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)), this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)), + this.proposals.onDidChangeProposals(({ uri }) => { + this.refreshByUri(uri); + this.updateStatus(uri); + }), + this.statusItem, ); + this.statusItem.command = "cowriting.showTrackChangesPreview"; } private isMarkdown(document: vscode.TextDocument): boolean { @@ -61,7 +71,7 @@ export class TrackChangesPreviewController implements vscode.Disposable { const name = path.basename(document.uri.path) || "untitled"; const panel = vscode.window.createWebviewPanel( VIEW_TYPE, - `Track changes: ${name}`, + `Review: ${name}`, { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, { enableScripts: true, @@ -75,22 +85,33 @@ export class TrackChangesPreviewController implements vscode.Disposable { this.panels.delete(key); this.lastModel.delete(key); this.mode.delete(key); + // A panel is gone: re-show the off-panel indicator if proposals remain. + this.updateStatus(key); }, null, this.disposables, ); - // F9: the webview's header toggle posts the chosen mode back. + // F10: the webview posts the annotations toggle + ✓/✗ proposal decisions back. panel.webview.onDidReceiveMessage( - (m: { type?: string; mode?: "changes" | "authorship" }) => { - if (m?.type === "setMode" && (m.mode === "changes" || m.mode === "authorship")) { + (m: { type?: string; mode?: "on" | "off"; proposalId?: string }) => { + if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) { this.mode.set(key, m.mode); this.refresh(document); + } else if (m?.type === "accept" && m.proposalId) { + void this.proposals + .acceptById(this.proposals.keyFor(document), m.proposalId) + .then(() => this.refresh(document)); + } else if (m?.type === "reject" && m.proposalId) { + this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId); + this.refresh(document); } }, null, this.disposables, ); this.panels.set(key, panel); + // A panel is now open for this doc — the off-panel indicator is redundant. + this.hideStatus(); this.refresh(document); } @@ -118,41 +139,58 @@ export class TrackChangesPreviewController implements vscode.Disposable { const key = document.uri.toString(); const panel = this.panels.get(key); if (!panel) return; - const mode = this.mode.get(key) ?? "changes"; + const mode = this.mode.get(key) ?? "on"; 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 baselineText = baseline?.text ?? current; // no baseline → no change-marks const ops = diffBlocks(baselineText, current); this.lastModel.set(key, ops); + if (mode === "off") { + void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current) }); + return; + } + const spans = this.attribution.spansFor(document); + const proposals = this.proposals.listProposals(document); 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, + proposals: proposals.length, }; void panel.webview.postMessage({ type: "render", mode, - html: renderTrackChanges(baselineText, current), + html: renderReview(baselineText, current, spans, proposals), epoch: this.epochLabel(baseline), summary, }); } + /** F10 (PUC-6): off-panel proposal indicator on the active doc. Hidden when a panel is open. */ + private updateStatus(uri: string): void { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); + if (!doc) { + this.hideStatus(); + return; + } + const n = this.proposals.listProposals(doc).length; + if (n === 0 || this.panels.has(uri)) { + this.hideStatus(); + return; + } + this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`; + this.statusItem.tooltip = "Cowriting: open the review preview to accept/reject Claude's proposals"; + this.statusItem.show(); + } + + /** + * Hide the off-panel indicator AND clear its text, so the `statusText()` seam + * is honest: a hidden indicator reports `undefined` (not its stale last value). + */ + private hideStatus(): void { + this.statusItem.text = ""; + this.statusItem.hide(); + } + private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string { if (!baseline) return "opened (no baseline yet)"; const time = new Date(baseline.capturedAt).toLocaleTimeString(); @@ -193,13 +231,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
-
- - -
- Track changes + + Review - +
@@ -214,24 +249,33 @@ export class TrackChangesPreviewController implements vscode.Disposable { getLastModel(uriString: string): BlockOp[] | undefined { return this.lastModel.get(uriString); } - /** F7.1 (#22) test seam: the track-changes HTML the panel would post for a doc. */ + /** F10 test seam: the review HTML the panel would post for a doc (on-state). */ renderHtmlFor(uriString: string): string { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); if (!doc) return ""; const current = doc.getText(); const baseline = this.diffView.getBaseline(uriString); - return renderTrackChanges(baseline?.text ?? current, current); + return renderReview( + baseline?.text ?? current, + current, + this.attribution.spansFor(doc), + this.proposals.listProposals(doc), + ); } - /** F9: current view mode for a panel (default track-changes). */ - getMode(uriString: string): "changes" | "authorship" { - return this.mode.get(uriString) ?? "changes"; + /** F10: current annotations mode for a panel (default on). */ + getMode(uriString: string): "on" | "off" { + return this.mode.get(uriString) ?? "on"; } - /** F9: set the view mode and re-render (the programmatic twin of the header toggle). */ - setMode(uriString: string, mode: "changes" | "authorship"): void { + /** F10: set the annotations mode and re-render (the programmatic twin of the header toggle). */ + setMode(uriString: string, mode: "on" | "off"): void { this.mode.set(uriString, mode); const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); if (doc) this.refresh(doc); } + /** F10 test seam (SLICE-4 E2E): the off-panel status-bar indicator text, if shown. */ + statusText(): string | undefined { + return this.statusItem.text || undefined; + } dispose(): void { for (const t of this.debounces.values()) clearTimeout(t); diff --git a/test/e2e/suite-no-workspace/noWorkspace.test.ts b/test/e2e/suite-no-workspace/noWorkspace.test.ts index b36ddc0..0201df9 100644 --- a/test/e2e/suite-no-workspace/noWorkspace.test.ts +++ b/test/e2e/suite-no-workspace/noWorkspace.test.ts @@ -29,14 +29,20 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => { "cowriting.resolveThread", "cowriting.reopenThread", "cowriting.editSelection", - "cowriting.toggleAttribution", "cowriting.applyAgentEdit", - "cowriting.acceptProposal", - "cowriting.rejectProposal", "cowriting.proposeAgentEdit", ]) { assert.ok(all.includes(command), `${command} is registered`); } + // F10 (INV-32): proposals are preview-only — the in-editor accept/reject + // commands and the attribution toggle were retired (no editor decorations). + for (const retired of [ + "cowriting.toggleAttribution", + "cowriting.acceptProposal", + "cowriting.rejectProposal", + ]) { + assert.ok(!all.includes(retired), `${retired} is retired (F10 preview-only)`); + } }); test("authoring works folder-less: propose→accept on an untitled buffer routes to global storage (F8)", async () => { diff --git a/test/e2e/suite/attribution.test.ts b/test/e2e/suite/attribution.test.ts index 5bc615d..a1b1010 100644 --- a/test/e2e/suite/attribution.test.ts +++ b/test/e2e/suite/attribution.test.ts @@ -132,7 +132,7 @@ suite("F3 live attribution (host E2E — seam-driven, no LLM)", () => { assert.ok(api.attributionController.getOrphanCount(DOC_REL) >= 1, "…it is orphaned instead (INV-1)"); }); - test("the applyAgentEdit command wrapper and the toggle command work end-to-end", async () => { + test("the applyAgentEdit command wrapper works end-to-end (data layer; no editor decorations — F10/INV-32)", async () => { const doc = await openDoc(); const api = await getApi(); const anchor = "stable first paragraph"; @@ -153,10 +153,9 @@ suite("F3 live attribution (host E2E — seam-driven, no LLM)", () => { assert.ok(agent, "command-driven agent span exists"); assert.strictEqual(agent!.authorKind, "agent"); - assert.strictEqual(api.attributionController.isVisible(), true); - await vscode.commands.executeCommand("cowriting.toggleAttribution"); - assert.strictEqual(api.attributionController.isVisible(), false, "toggle hides (PUC-5)"); - await vscode.commands.executeCommand("cowriting.toggleAttribution"); - assert.strictEqual(api.attributionController.isVisible(), true, "toggle restores"); + // F10/INV-32: the editor carries no attribution decorations and the toggle was + // retired — the rendered preview is the single review surface. + const all = await vscode.commands.getCommands(true); + assert.ok(!all.includes("cowriting.toggleAttribution"), "the in-editor attribution toggle is retired (F10)"); }); }); diff --git a/test/e2e/suite/authorship.test.ts b/test/e2e/suite/authorship.test.ts index b37f4d7..638c161 100644 --- a/test/e2e/suite/authorship.test.ts +++ b/test/e2e/suite/authorship.test.ts @@ -14,16 +14,20 @@ async function getApi(): Promise { 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"; +// F10 host E2E (no LLM): the rewrite of the obsolete F9 authorship-mode test. +// F9's "authorship" mode / renderAuthorship is gone — the on-state renderReview +// now author-colors Claude's landed prose. This suite confirms a Claude-landed +// span renders as a cw-by-claude span in the on-state preview HTML. Owns its own +// markdown doc, disjoint from the other suites' fixtures. +suite("F10 review preview — Claude-authored prose is cw-by-claude in the on-state (host E2E, no LLM)", () => { + const DOC_REL = "docs/f10claude.md"; const TARGET = "The sentence Claude will compose over."; + const REPLACEMENT = "The sentence CLAUDE COMPOSED via the seam."; - test("authorship mode marks Claude's accepted edit; track-changes mode still works", async () => { + test("an accepted Claude edit author-colors as cw-by-claude in the on-state render; mode defaults to on", async () => { const abs = path.join(WS, DOC_REL); fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, `# F9\n\n${TARGET}\n`, "utf8"); + fs.writeFileSync(abs, `# F10\n\n${TARGET}\n`, "utf8"); const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); @@ -31,11 +35,11 @@ suite("F9 authorship preview (host E2E — seam ingress, no LLM)", () => { const api = await getApi(); const key = uri.toString(); - // open the preview (track-changes mode by default) + // open the preview — annotations default ON (F10/INV-33) 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"); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default to on"); // Claude composes via the seam (propose → accept) const start = doc.getText().indexOf(TARGET); @@ -43,32 +47,22 @@ suite("F9 authorship preview (host E2E — seam ingress, no LLM)", () => { uri: key, start, end: start + TARGET.length, - newText: "The sentence CLAUDE COMPOSED.", + newText: REPLACEMENT, model: "sonnet", - sessionId: "e2e-f9", - turnId: "turn-f9", + sessionId: "e2e-f10", + turnId: "turn-f10", }); - assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies"); + assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam"); await settle(); - // attribution has a Claude span now + // attribution recorded a Claude (agent) span (data layer intact) 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", - ); + // the on-state render author-colors the landed Claude text as cw-by-claude + const html = api.trackChangesPreviewController.renderHtmlFor(key); + assert.match(html, //, "landed Claude prose is author-colored in the on-state render"); }); }); diff --git a/test/e2e/suite/f10Review.test.ts b/test/e2e/suite/f10Review.test.ts new file mode 100644 index 0000000..94b719a --- /dev/null +++ b/test/e2e/suite/f10Review.test.ts @@ -0,0 +1,238 @@ +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"; +import { renderPlain } from "../../../src/trackChangesModel"; + +const WS = process.env.E2E_WORKSPACE!; +const settle = () => new Promise((r) => setTimeout(r, 400)); + +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; +} + +/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */ +async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { + const abs = path.join(WS, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body, "utf8"); + const uri = vscode.Uri.file(abs); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await settle(); + return { doc, key: uri.toString() }; +} + +async function propose( + doc: vscode.TextDocument, + key: string, + target: string, + newText: string, + turnId: string, +): Promise { + const start = doc.getText().indexOf(target); + assert.ok(start >= 0, `fixture contains "${target}"`); + const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { + uri: key, + start, + end: start + target.length, + newText, + model: "sonnet", + sessionId: "e2e-f10rev", + turnId, + }); + assert.ok(id, "propose returns an id"); + return id!; +} + +// F10 host E2E (no LLM): the rendered preview is the single INTERACTIVE review +// surface. This suite owns docs/f10review.md (its main flow is order-dependent) +// plus its own disjoint fresh docs for the isolated cases (status-bar, toggle). +// The editor is zero-decoration; everything observable here is the data layer + +// the on-state renderReview HTML the panel posts. +suite("F10 interactive review (host E2E — preview is the single review surface, no LLM)", () => { + const DOC_REL = "docs/f10review.md"; + const PROSE = "The original review paragraph that lives in this doc."; + const T1 = "A first claude target sentence here."; + const T2 = "A second claude target sentence here."; + + test("open on a markdown doc → panel open, fresh baseline all-unchanged, mode is on (PUC-1)", async () => { + const { doc, key } = await freshDoc(DOC_REL, `# F10 review\n\n${PROSE}\n\n${T1}\n\n${T2}\n`); + const api = await getApi(); + assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel yet"); + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open"); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default on (INV-33)"); + const model = api.trackChangesPreviewController.getLastModel(key); + assert.ok(model && model.length > 0, "a model was computed"); + assert.ok(model!.every((o) => o.kind === "unchanged"), "fresh baseline == buffer → every block unchanged"); + void doc; + }); + + test("typing produces an added/changed block and a cw-by-human span in the on-state render (PUC-2)", async () => { + const { key } = await reopen(DOC_REL); + const api = await getApi(); + const doc = byKey(key)!; + const edit = new vscode.WorkspaceEdit(); + edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n"); + assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied"); + await settle(); + const kinds = (api.trackChangesPreviewController.getLastModel(key) ?? []).map((o) => o.kind); + assert.ok(kinds.some((k) => k === "added" || k === "changed"), "an added/changed block after typing"); + // Attribution recorded the human span (data layer), and the on-state render + // author-colors that prose as cw-by-human. + assert.ok( + api.attributionController.spansFor(doc).some((s) => s.author === "human"), + "a human span was recorded for the typed text", + ); + const html = api.trackChangesPreviewController.renderHtmlFor(key); + assert.match(html, //, "typed text is author-colored human in the on-state"); + }); + + test("propose → the preview surfaces it as a cw-proposal block with ✓/✗ actions (PUC-3)", async () => { + const { doc, key } = await reopen(DOC_REL); + const api = await getApi(); + const id = await propose(doc, key, T1, "A FIRST claude REPLACEMENT sentence.", "turn-f10-1"); + await settle(); + const views = api.proposalController.listProposals(doc); + assert.ok(views.some((v) => v.id === id), "listProposals returns a view with the id"); + const html = api.trackChangesPreviewController.renderHtmlFor(key); + assert.ok(html.includes(`data-proposal-id="${id}"`), "the preview renders the proposal block by id"); + assert.match(html, /class="cw-actions"/, "the proposal block carries ✓/✗ actions"); + // INV-10: proposing never touches the document. + assert.ok(doc.getText().includes(T1), "document unchanged by propose"); + }); + + test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => { + const { doc, key } = await reopen(DOC_REL); + const api = await getApi(); + const id = api.proposalController.listProposals(doc).find((v) => v.replaced === T1)!.id; + assert.ok(await api.proposalController.acceptById(DOC_REL, id), "accept applies via the seam"); + await settle(); + const replacement = "A FIRST claude REPLACEMENT sentence."; + assert.ok(doc.getText().includes(replacement), "replacement landed in the document"); + assert.ok(!doc.getText().includes(T1), "original target gone"); + assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id), "proposal cleared from listProposals"); + const html = api.trackChangesPreviewController.renderHtmlFor(key); + assert.ok(!html.includes(`data-proposal-id="${id}"`), "the accepted proposal block is gone from the preview"); + // The baseline advanced on the landing (INV-18): the landed text is not marked. + const model = api.trackChangesPreviewController.getLastModel(key) ?? []; + const marked = model.some((o) => o.kind !== "unchanged" && o.block.raw.includes("FIRST claude REPLACEMENT")); + assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)"); + }); + + test("reject → the proposal vanishes and the document is untouched (PUC-5)", async () => { + const { doc, key } = await reopen(DOC_REL); + const api = await getApi(); + const before = doc.getText(); + const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2"); + await settle(); + assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending"); + assert.strictEqual(api.proposalController.rejectById(DOC_REL, id2), true, "reject"); + await settle(); + assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone"); + assert.strictEqual(doc.getText(), before, "document untouched by reject"); + const html = api.trackChangesPreviewController.renderHtmlFor(key); + assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview"); + }); + + test("toggle annotations off → mode round-trips and the off-state render is plain (no cw- marks) (INV-33)", async () => { + // A fresh doc with a pending proposal so the on-state DOES carry a cw- mark, + // making the off-state's absence of marks meaningful (not tautological). + const { doc, key } = await freshDoc( + "docs/f10toggle.md", + "# F10 toggle\n\nA toggle target sentence to propose over.\n", + ); + const api = await getApi(); + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + const id = await propose(doc, key, "A toggle target sentence to propose over.", "A TOGGLED replacement.", "turn-tog"); + await settle(); + + // on-state: the proposal block + its actions are present. + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "starts on"); + const onHtml = api.trackChangesPreviewController.renderHtmlFor(key); + assert.ok(onHtml.includes(`data-proposal-id="${id}"`), "on-state shows the proposal block"); + + // toggle off → mode round-trips; the off-state body is plain markdown. + api.trackChangesPreviewController.setMode(key, "off"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "off", "mode flipped to off"); + assert.ok(api.trackChangesPreviewController.isOpen(key), "panel stays open across the toggle"); + // renderHtmlFor is the on-state seam; the off-state body is renderPlain(current) + // (INV-33). Assert the actual off-state body the controller posts has no cw- + // author/proposal marks — meaningful because the on-state above DID carry one. + const offBody = renderPlain(doc.getText()); + assert.ok(!/cw-proposal|cw-by-claude|cw-by-human|cw-del/.test(offBody), "off-state render carries no cw- marks"); + + // toggle back on → marks return. + api.trackChangesPreviewController.setMode(key, "on"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "mode flipped back on"); + assert.ok( + api.trackChangesPreviewController.renderHtmlFor(key).includes(`data-proposal-id="${id}"`), + "on-state shows the proposal block again", + ); + }); + + test("status-bar (PUC-6): a pending proposal with NO panel shows the indicator; opening the preview hides it", async () => { + // Isolated fresh doc: no preview opened, so the off-panel indicator is live. + const { doc, key } = await freshDoc("docs/f10status.md", "# F10 status\n\nA status target sentence here.\n"); + const api = await getApi(); + assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for this doc"); + await propose(doc, key, "A status target sentence here.", "A STATUS replacement.", "turn-stat"); + await settle(); + const text = api.trackChangesPreviewController.statusText(); + assert.ok(text && text.length > 0, "the off-panel indicator shows a non-empty status"); + assert.match(text!, /1 Claude proposal/, "it mentions the pending count"); + + // open the preview for this doc → the off-panel indicator hides (undefined). + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open"); + assert.strictEqual(api.trackChangesPreviewController.statusText(), undefined, "indicator hidden once the panel is open"); + }); + + test("clean editor: data layers intact AND the retired in-editor surfaces are gone (INV-32)", async () => { + const { doc, key } = await freshDoc("docs/f10clean.md", "# F10 clean\n\nA clean target sentence here.\n"); + const api = await getApi(); + const id = await propose(doc, key, "A clean target sentence here.", "A CLAUDE clean replacement.", "turn-clean"); + await settle(); + assert.ok(await api.proposalController.acceptById("docs/f10clean.md", id), "accept lands the Claude edit"); + await settle(); + // data layer intact: a Claude (agent) attribution span exists. + assert.ok( + api.attributionController.getSpans("docs/f10clean.md").some((s) => s.authorKind === "agent"), + "agent span recorded (attribution data layer intact)", + ); + // the retired toggle is gone from the palette (no editor decorations — INV-32). + const all = await vscode.commands.getCommands(true); + assert.ok(!all.includes("cowriting.toggleAttribution"), "cowriting.toggleAttribution is retired"); + // the F6 diff toggle's keybinding is hidden (when:false) — it is not a user surface. + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); + const dKb = (pkg.contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find( + (k) => k.command === "cowriting.toggleDiffView" && k.key === "ctrl+alt+d", + ); + assert.ok(dKb, "the ctrl+alt+d toggleDiffView keybinding is declared"); + assert.strictEqual(dKb!.when, "false", "…but hidden (when:false) — F6 is a data layer, not a user surface"); + void key; + }); +}); + +// ---- helpers reused across the order-dependent main-flow tests ---- + +function byKey(key: string): vscode.TextDocument | undefined { + return vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); +} +async function reopen(rel: string): Promise<{ doc: vscode.TextDocument; key: string }> { + const uri = vscode.Uri.file(path.join(WS, rel)); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await settle(); + return { doc, key: uri.toString() }; +} diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 0297ee1..5a2c306 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, renderAuthorship, type AuthorSpan } from "../src/trackChangesModel"; +import { describe, it, test, expect } from "vitest"; +import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel"; describe("splitBlocks", () => { it("splits prose paragraphs on blank lines, dropping empties", () => { @@ -159,79 +159,77 @@ describe("splitBlocksWithRanges — block offsets align with the source string", }); }); -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."); +describe("colorByAuthor", () => { + test("colorByAuthor wraps human-authored prose in cw-by-human spans", () => { + const raw = "hello world"; + const spans: AuthorSpan[] = [{ start: 0, end: 5, author: "human" }]; + const render = (src: string) => `

${src}

`; + const html = colorByAuthor(raw, 0, spans, render); + expect(html).toContain('hello'); + expect(html).toContain("world"); }); +}); - 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'); +import { renderPlain } from "../src/trackChangesModel"; + +describe("renderPlain", () => { + test("renderPlain renders current buffer as plain markdown (no marks)", () => { + const html = renderPlain("# Title\n\nhello"); + expect(html).toContain("

Title

"); + expect(html).toContain("hello"); + expect(html).not.toContain("cw-"); }); +}); - 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'); +import { renderReview, type ProposalView } from "../src/trackChangesModel"; + +describe("renderReview", () => { + test("renderReview: human addition since baseline renders green ins / cw-by-human", () => { + const html = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []); + expect(html).toMatch(/[^<]*world[^<]*<\/ins>|cw-by-human/); }); - - 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'); + test("renderReview: deletion since baseline renders struck del/cw-del", () => { + const html = renderReview("hello world", "hello", [], []); + expect(html).toMatch(/|cw-del/); }); - - 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.'); + test("renderReview: a pending proposal renders a blue block with data-proposal-id and ✓/✗ actions", () => { + const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }]; + const html = renderReview("hello", "hello", [], proposals); + expect(html).toContain('class="cw-proposal"'); + expect(html).toContain('data-proposal-id="p1"'); + expect(html).toContain("cw-actions"); + expect(html).toContain("goodbye"); + expect(html).toMatch(/]*>[^<]*hello[^<]*<\/del>|cw-del/); }); - - 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;"); + test("renderReview: an unresolved proposal renders as a trailing block (never dropped)", () => { + const proposals: ProposalView[] = [{ id: "p2", anchorStart: null, anchorEnd: null, replaced: "x", replacement: "y" }]; + const html = renderReview("a", "a", [], proposals); + expect(html).toContain('data-proposal-id="p2"'); + expect(html).toContain("cw-proposal-unanchored"); }); - - 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"); + test("renderReview is deterministic (same inputs → identical HTML)", () => { + const a = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []); + const b = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []); + expect(a).toBe(b); }); - - 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]/); + test("renderReview: an atomic mermaid change is diffed whole (no inner author sentinels)", () => { + const base = "```mermaid\nflowchart LR\n A --> B\n```"; + const cur = "```mermaid\nflowchart LR\n A --> C\n```"; + const html = renderReview(base, cur, [], []); + expect(html).toContain("mermaid"); + expect(html).not.toContain("cw-by-"); }); - - it("is deterministic", () => { - const text = "Stable input paragraph.\n"; - const spans: AuthorSpan[] = [spanAt(text, "input", "claude")]; - expect(renderAuthorship(text, spans)).toBe(renderAuthorship(text, spans)); + test("renderReview: author-colors the correct block when two paragraphs are identical", () => { + // Two identical paragraphs; baseline has only the first, so the SECOND is an + // added block authored by human. Its span must color THAT block, not the first. + const baseline = "Hello world"; + const current = "Hello world\n\nHello world"; + // second "Hello world" starts at offset 13; "world" at 19..24 + const spans = [{ start: 19, end: 24, author: "human" as const }]; + const html = renderReview(baseline, current, spans, []); + // exactly one cw-by-human span (the added second block's "world"), not zero, not on the first. + const count = (html.match(/cw-by-human/g) ?? []).length; + expect(count).toBe(1); }); });