From 2170a0d282f3936b06f91fbb1f705cd974d11555 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 14:51:18 -0700 Subject: [PATCH] =?UTF-8?q?feat!:=20sunset=20the=20review-panel=20webview?= =?UTF-8?q?=20=E2=80=94=20built-in=20preview=20+=20native=20diff=20are=20t?= =?UTF-8?q?he=20review=20surfaces=20(D3/D17,=20spec=20=C2=A76.10);=20Keep/?= =?UTF-8?q?Reject=20lens=20copy=20(PUC-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes the last bespoke UI surface (TrackChangesPreviewController + its sealed webview client assets); every entry point re-points at native VS Code chrome per spec §5: the #41 right-click entries become cowriting.openReviewPreview (enter coediting if needed -> "Open Preview to the Side"), Ctrl+Alt+R/Cmd+Alt+R moves to cowriting.reviewChanges (native diff), and the F12 CodeLens per-proposal titles read "Keep"/"Reject" with a top-of-file "Keep all (N)"/"Reject all" pair once >=2 proposals are pending. EditFlow drops its own askClaude/askEditInstruction (the webview's only caller) and its now-unused constructor params. Coverage that lived only in the webview's test seams (renderHtmlFor, receiveMessage, isOpen, ...) moves to direct calls against the surviving controllers/pure renderReview (test/e2e/suite/helpers.ts gains a shared renderHtmlFor probe); a genuine gap (pending-proposal + unchanged-block rendering) is backfilled in test/previewAnnotations.test.ts. README's "how it works" is rewritten as the native-surface map; the superseded F6/F7/F9/ F10/F11 sections are kept as a marked historical record rather than deleted outright. 292 unit + 91 E2E green. Co-Authored-By: Claude Fable 5 --- README.md | 97 ++++- esbuild.mjs | 26 +- media/preview.css | 118 ------- media/preview.ts | 157 --------- package.json | 14 +- src/editFlow.ts | 113 +----- src/editorProposalController.ts | 26 +- src/extension.ts | 77 ++-- src/trackChangesPreview.ts | 391 --------------------- test/e2e/suite/authorship.test.ts | 19 +- test/e2e/suite/f10Review.test.ts | 310 ---------------- test/e2e/suite/f11Toolbar.test.ts | 102 +----- test/e2e/suite/f12Accept.test.ts | 21 +- test/e2e/suite/f12InlineDiff.test.ts | 24 +- test/e2e/suite/f12Reach.test.ts | 2 +- test/e2e/suite/f12Review.test.ts | 54 ++- test/e2e/suite/helpers.ts | 20 ++ test/e2e/suite/liveProgress.test.ts | 6 +- test/e2e/suite/reviewPanelMenu.test.ts | 119 +++++-- test/e2e/suite/s48PinClean.test.ts | 17 +- test/e2e/suite/trackChangesPreview.test.ts | 140 -------- test/e2e/suite/undoMarks.test.ts | 5 +- test/previewAnnotations.test.ts | 29 ++ 23 files changed, 412 insertions(+), 1475 deletions(-) delete mode 100644 media/preview.css delete mode 100644 media/preview.ts delete mode 100644 src/trackChangesPreview.ts delete mode 100644 test/e2e/suite/f10Review.test.ts delete mode 100644 test/e2e/suite/trackChangesPreview.test.ts diff --git a/README.md b/README.md index 1a4b979..4cfc315 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,59 @@ 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), F10 interactive review — **write left / review -right** (Feature #29), and F11 — the **preview toolbar as the primary -interaction surface** (Feature #43). +data layer (Feature #17), and the **native-surfaces migration** (the +"Coediting Markdown with a Machine" Solution Design) — the current UI, replacing +the bespoke F7/F10/F11 review webview with VS Code's own chrome end to end. See +**How it works** below for the current experience; the F7/F9/F10/F11 sections +further down are kept as a **historical record** of the superseded webview (each +now says so up top). + +## How it works + +There is **no bespoke panel**. Every surface below is native VS Code chrome — +the editor title bar, status bar, Comments gutter, source control, diff editor, +and the built-in Markdown preview: + +1. **Enter coediting.** Run **"✦ Coedit this Document with Claude"** (command + palette, editor title menu, or editor/explorer context menu) on a Markdown + document — it captures a **baseline** (a clean checkpoint: git HEAD if the + file is tracked and clean, otherwise a snapshot) to measure future changes + against. Until a document is entered, none of the surfaces below attach to + it. **"Stop editing with Claude"** reverses it. +2. **Title bar + status bar are the everyday entry points.** Once coedited, the + editor title bar carries **Review Changes**, **Ask Claude**, **Toggle + Annotations**, and (in snapshot mode) **Mark Changes as Reviewed**; a + `✦ Coediting · N changes` status-bar item shows the pending-change count and + opens the review when clicked — no need to find the Source Control pane. +3. **Ask Claude is comments-first.** **"Ask Claude"** (editor title, or select + text → right-click → "Ask Claude to Edit") opens a focused comment box on + the native **Comments API** — on your selection, or a top-anchored + whole-document thread with none selected. Claude replies in-thread and + offers an edit; accepting the offer turns it into one or more pending + proposals. +4. **Pending changes live in the buffer.** A proposal is **optimistically + applied** straight into the editor (green/blue insertion tint, a struck + deletion hint) with an inline **`✓ Keep` / `✗ Reject`** CodeLens above each + changed block — and, once ≥2 proposals are pending, a top-of-file + **`✓ Keep all (N)` / `✗ Reject all`** pair. Nothing is force-applied: a + proposal whose target text changed underneath it is skipped, never guessed. +5. **Native diff answers "what changed?"** **Review Changes** (editor title, or + `Ctrl+Alt+R` / `Cmd+Alt+R`) opens VS Code's own diff editor — baseline on the + left, your live document on the right — with the platform's inline/side-by-side + toggle, navigation, and accessibility for free. A Source Control gutter/viewlet + entry (quick-diff change bars, "Open Changes") is a bonus home for git users, + never the required path. +6. **The built-in Markdown preview is the annotated read.** "Open Preview to the + Side" (or right-click a markdown file/tab → **"Open Cowriting Review + Preview"**, `cowriting.openReviewPreview`) renders the document with + authorship/change coloring **inside VS Code's own preview** — green = human, + blue = Claude, strikethrough = deleted — toggled by **Toggle Annotations** + (command or the title-bar eye icon). No separate webview, no separate + persistence. + +Design: `vscode-cowriting-plugin-content/specs/coauthoring-native-surfaces.md` +(§5 UX Layout is the canonical source for this map). Migration plan: +[`docs/superpowers/plans/2026-07-01-native-surfaces-migration.md`](docs/superpowers/plans/2026-07-01-native-surfaces-migration.md). ## Architecture @@ -134,7 +184,16 @@ record per the contract; git push/pull is the transport, no re-homing ever. Design: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`. -## F6 — Diff-view toggle (Feature #17, #19) +## F6 — Diff-view toggle (Feature #17, #19) — SUPERSEDED + +> **Superseded.** The two-pane `Ctrl+Alt+D` toggle this section describes was +> deleted in #34; only the baseline **data layer** survived, now serving the +> native diff editor (`Ctrl+Alt+R` / "Review Changes" — How it works §5) and the +> built-in preview annotations (§6). The native-surfaces migration's Task 2 +> additionally **retired INV-18** (machine-landing auto-advance): a landed +> Claude edit stays a visible change until "Mark Changes as Reviewed" (the +> renamed "Pin Diff Baseline to Now"), it no longer auto-clears. Kept below as a +> historical record. **`Ctrl+Alt+D`** (the same chord on macOS — not `Cmd`; or **Cowriting: Toggle Diff View**) flips the focused document into a native `vscode.diff` against a @@ -166,7 +225,12 @@ instead of git archaeology. Design: `vscode-cowriting-plugin-content/specs/coauthoring-diff-view.md`. Live smoke: [`docs/MANUAL-SMOKE-F6.md`](docs/MANUAL-SMOKE-F6.md). -## F7 — Rendered track-changes preview (Feature #21) +## F7 — Rendered track-changes preview (Feature #21) — SUPERSEDED + +> **Superseded (Task 8, native-surfaces migration).** The bespoke webview this +> section describes was deleted; its authorship/change coloring lives on +> **inside VS Code's own built-in Markdown preview** — see **How it works** §6 +> above. Kept below as a historical record of the pre-migration design. **`Ctrl+Alt+R`** (or **Cowriting: Open Track-Changes Preview**) opens a read-only webview **beside** a **Markdown** editor that renders the document and @@ -198,7 +262,12 @@ the manual smoke, not the sealed-sandbox E2E. Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`. Live smoke: [`docs/MANUAL-SMOKE-F7.md`](docs/MANUAL-SMOKE-F7.md). -## F9 — Authorship view in the preview (Feature ~#27) +## F9 — Authorship view in the preview (Feature ~#27) — SUPERSEDED + +> **Superseded (Task 8, native-surfaces migration).** The F7 webview this mode +> lived in was deleted; the built-in preview's annotations (How it works §6) +> always show authorship + change coloring together — there is no separate +> mode toggle. Kept below as a historical record. The rendered preview (F7) gains a second mode, switched by a `[ Track changes | Authorship ]` toggle in its header. **Authorship** mode re-renders the current @@ -239,7 +308,13 @@ 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) +## F10 — Interactive review: write left / review right (Feature #29) — SUPERSEDED + +> **Superseded (Task 8, native-surfaces migration).** The editor is no longer +> "zero-annotation" — pending proposals now render optimistically applied +> in-buffer with `✓ Keep`/`✗ Reject` CodeLens (F12), and review happens across +> the native diff editor + built-in preview, not one dedicated webview. See +> **How it works** above. Kept below as a historical record. A clean, **zero-annotation editor** on the left; the rendered preview on the right as the **single interactive review surface**. The editor carries no @@ -266,7 +341,13 @@ 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). -## F11 — Preview toolbar as the primary interaction surface (Feature #43) +## F11 — Preview toolbar as the primary interaction surface (Feature #43) — SUPERSEDED + +> **Superseded (Task 8, native-surfaces migration).** The webview toolbar this +> section describes was deleted; its two controls moved to native chrome — +> **Pin baseline** is now **"Mark Changes as Reviewed"** in the editor title +> bar, and **Ask Claude** is the comments-first ask (How it works §3). Kept +> below as a historical record. The review preview's **header toolbar** becomes the cockpit for the inner loop. Beside the existing **Annotations** switch it gains two controls: diff --git a/esbuild.mjs b/esbuild.mjs index 762ec83..ca9a273 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -30,29 +30,15 @@ const liveTurnOptions = { logLevel: "info", }; -/** @type {import('esbuild').BuildOptions} */ -const previewOptions = { - entryPoints: ["media/preview.ts"], - outfile: "out/media/preview.js", - bundle: true, - // The webview is a browser context; mermaid is bundled IN (and ONLY in) this - // asset so it never bloats the extension-host bundle (the @cline/sdk size - // discipline). No externals — everything is shipped to the sealed webview. - platform: "browser", - format: "iife", - target: "es2020", - sourcemap: true, - logLevel: "info", -}; - /** @type {import('esbuild').BuildOptions} */ const previewMermaidOptions = { entryPoints: ["media/preview-mermaid.ts"], outfile: "out/media/preview-mermaid.js", bundle: true, // Task 7 (Q4): contributed via markdown.previewScripts into the BUILT-IN - // preview's webview — same browser/IIFE shape as previewOptions above, and - // the same reason mermaid is bundled here (never in the host bundle). + // preview's webview (a browser context) — mermaid is bundled IN (and ONLY + // in) this asset so it never bloats the extension-host bundle (the + // @cline/sdk size discipline). platform: "browser", format: "iife", target: "es2020", @@ -63,16 +49,14 @@ const previewMermaidOptions = { if (watch) { const ctx = await context(options); const ctxLive = await context(liveTurnOptions); - const ctxPreview = await context(previewOptions); const ctxPreviewMermaid = await context(previewMermaidOptions); - await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch(), ctxPreviewMermaid.watch()]); + await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreviewMermaid.watch()]); console.log("esbuild: watching…"); } else { await build(options); await build(liveTurnOptions); - await build(previewOptions); await build(previewMermaidOptions); console.log( - "esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js + out/media/preview-mermaid.js", + "esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview-mermaid.js", ); } diff --git a/media/preview.css b/media/preview.css deleted file mode 100644 index f706ff1..0000000 --- a/media/preview.css +++ /dev/null @@ -1,118 +0,0 @@ -/* F7 track-changes preview — theme-aware via VS Code webview CSS variables. */ -body { - font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); - color: var(--vscode-foreground); - background: var(--vscode-editor-background); - padding: 0 1.2rem 2rem; - line-height: 1.5; -} -#cw-header { - position: sticky; - top: 0; - background: var(--vscode-editor-background); - border-bottom: 1px solid var(--vscode-panel-border); - padding: 0.5rem 0; - font-size: 0.85em; - opacity: 0.85; - display: flex; - gap: 1rem; -} -/* Author-colored track changes — style = operation, color = author. - underline = inserted, strikethrough = removed; human green/red, Claude blue/purple. */ -#cw-summary .cw-add { color: #3fb950; } -#cw-summary .cw-del { color: #f85149; } -.cw-blk { position: relative; } - -/* base ins/del carry the operation; author classes carry the color */ -ins { text-decoration: none; } -del { text-decoration: line-through; opacity: 0.7; } - -.cw-ins-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; } -.cw-ins-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; } -.cw-ins-none { background: var(--vscode-diffEditor-insertedTextBackground, rgba(63,185,80,0.14)); text-decoration: none; } -.cw-del-human { background: rgba(248,81,73,0.11); text-decoration: line-through; text-decoration-color: #f85149; } -.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; } -.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; } - -/* Task 8 — overlap: if a future render path nests cw-ins-{A} inside cw-del-{B}, - both marks must show. cw-ins-* sets text-decoration:none which would otherwise - suppress the parent del's strikethrough. `inherit` restores the parent's - line-through while the child's border-bottom underline is unaffected. - The engine currently emits SIBLING ins/del (never nested) so this rule is a - forward defensive guarantee only. See task-8-report.md for details. */ -.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude { text-decoration: inherit; } - -/* whole-block ops: an added block is an insertion (its author runs are emitted as - cw-ins-* via colorByAuthor with kind="ins"); a standalone removed block is neutral - struck (adjacency heuristic fallback) */ -.cw-added { background: transparent; } -.cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.10)); text-decoration: line-through; opacity: 0.65; } -.cw-changed { outline: none; } - -#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; } -#cw-summary .cw-prop { opacity: 0.85; } -.cw-badge { - position: absolute; - top: 0; - right: 0; - font-size: 0.7em; - padding: 0 0.4em; - border-radius: 3px; - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); -} -.cw-error { - border: 1px solid var(--vscode-inputValidation-errorBorder); - background: var(--vscode-inputValidation-errorBackground); - color: var(--vscode-errorForeground); - padding: 0.3rem 0.6rem; - border-radius: 3px; - font-size: 0.85em; -} -pre.mermaid { text-align: center; background: transparent; } -pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); } - - -/* F11 — preview toolbar buttons (Pin baseline; adaptive Ask Claude). Theme-aware. */ -#cw-header button { - cursor: pointer; - font: inherit; - border: 1px solid var(--vscode-button-border, transparent); - border-radius: 3px; - padding: 0.1em 0.55em; - background: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); -} -#cw-header button:hover:not(:disabled) { background: var(--vscode-button-secondaryHoverBackground); } -#cw-header button:disabled { opacity: 0.5; cursor: default; } - -/* 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-panel-border, #555); - background: color-mix(in srgb, var(--vscode-foreground) 5%, 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-btngroup { display: inline-flex; } -.cw-btngroup .cw-caret { border-left: none; padding: 0.1em 0.25em; } -.cw-actions .cw-accept { font-weight: 600; } -.cw-accept:hover, .cw-btngroup:has(.cw-accept) .cw-caret:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; } -.cw-reject:hover, .cw-btngroup:has(.cw-reject) .cw-caret: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; } -.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; } -.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; } -.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; } -.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; } diff --git a/media/preview.ts b/media/preview.ts deleted file mode 100644 index f4769e2..0000000 --- a/media/preview.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * F7 preview webview client (sealed sandbox, INV-21). Receives annotated HTML - * from the extension host and swaps it in; runs mermaid over `.mermaid` blocks - * (mermaid needs a DOM, so it runs here, not in the host). Bundled by esbuild as - * a standalone IIFE → out/media/preview.js, so mermaid never enters the host - * bundle. No network, no LLM. - */ -import mermaid from "mermaid"; -// Imported so esbuild emits the sibling out/media/preview.css (the controller -// links it into the sealed shell via asWebviewUri). -import "./preview.css"; - -declare function acquireVsCodeApi(): { postMessage(m: unknown): void }; - -interface RenderMessage { - type: "render"; - mode: "on" | "off"; - html: string; - epoch?: string; - summary?: { added: number; removed: number; proposals: number }; - /** F11: false on a non-authorable doc → Pin + Ask-Claude controls disabled. */ - authorable?: boolean; -} - -const vscodeApi = acquireVsCodeApi(); -const body = document.getElementById("cw-body")!; -const header = document.getElementById("cw-epoch")!; -const summary = document.getElementById("cw-summary")!; -const legend = document.getElementById("cw-legend")!; -const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null; -const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null; -const askEl = document.getElementById("cw-ask") as HTMLButtonElement | null; -const acceptAllEl = document.getElementById("cw-acceptall") as HTMLButtonElement | null; - -// F10: the annotations on/off toggle. -annotationsEl?.addEventListener("change", () => { - vscodeApi.postMessage({ type: "setMode", mode: annotationsEl.checked ? "on" : "off" }); -}); - -// F11 (SLICE-1): Pin baseline — post intent; the host pins via the F6 store (INV-35). -pinEl?.addEventListener("click", () => { - vscodeApi.postMessage({ type: "pinBaseline" }); -}); - -// F11 (SLICE-4): the single adaptive Ask-Claude button. Its label flips on -// `selectionchange` (Edit Selection when live text is selected in the preview, -// Edit Document otherwise), and a click resolves the selection to a SOURCE range -// via the nearest `data-src` ancestors (INV-36) — the webview's sole mapping -// duty. A selection that resolves to no live block falls back to document scope. - -/** Walk up from a DOM node to the nearest block carrying data-src offsets (INV-36). */ -function nearestSrc(node: Node | null): HTMLElement | null { - let el: HTMLElement | null = node instanceof HTMLElement ? node : (node?.parentElement ?? null); - while (el && el !== body) { - if (el.dataset.srcStart !== undefined && el.dataset.srcEnd !== undefined) return el; - el = el.parentElement; - } - return null; -} - -/** The source [start,end) union of the live blocks a non-empty body selection touches, or null. */ -function selectionSrcRange(): { start: number; end: number } | null { - const sel = window.getSelection(); - if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null; - const ends = [nearestSrc(sel.anchorNode), nearestSrc(sel.focusNode)].filter( - (e): e is HTMLElement => e !== null, - ); - if (ends.length === 0) return null; // selection touches no live-source block - const starts = ends.map((e) => Number(e.dataset.srcStart)); - const stops = ends.map((e) => Number(e.dataset.srcEnd)); - return { start: Math.min(...starts), end: Math.max(...stops) }; -} - -function updateAskLabel(): void { - if (!askEl) return; - askEl.textContent = selectionSrcRange() - ? "✦ Ask Claude to Edit Selection" - : "✦ Ask Claude to Edit Document"; -} - -document.addEventListener("selectionchange", updateAskLabel); - -askEl?.addEventListener("click", () => { - const range = selectionSrcRange(); - if (range) { - vscodeApi.postMessage({ type: "askClaude", scope: "selection", start: range.start, end: range.end }); - } else { - vscodeApi.postMessage({ type: "askClaude", scope: "document" }); - } -}); - -// #46 (INV-42): Accept all — batch-accept every pending proposal (intent only). -acceptAllEl?.addEventListener("click", () => { - vscodeApi.postMessage({ type: "acceptAll" }); -}); - -// F12/#64: Accept/Reject (+ caret → accept-all/reject-all) on a proposal block. -body.addEventListener("click", (e) => { - const btn = (e.target as HTMLElement)?.closest(".cw-actions button"); - if (!btn) return; - const id = btn.closest(".cw-proposal")?.dataset.proposalId; - const action = btn.dataset.action; - if (action === "acceptAll") return void vscodeApi.postMessage({ type: "acceptAll" }); - if (action === "rejectAll") return void vscodeApi.postMessage({ type: "rejectAll" }); - if (id && (action === "accept" || action === "reject")) { - vscodeApi.postMessage({ type: action, proposalId: id }); - } -}); - -function themeFor(): "dark" | "default" { - return document.body.classList.contains("vscode-dark") || - document.body.classList.contains("vscode-high-contrast") - ? "dark" - : "default"; -} - -async function renderMermaid(): Promise { - const nodes = Array.from(body.querySelectorAll("pre.mermaid")); - if (nodes.length === 0) return; - mermaid.initialize({ startOnLoad: false, theme: themeFor(), securityLevel: "strict" }); - try { - await mermaid.run({ nodes }); - } catch { - // mermaid.run already marks failed nodes; ensure a visible chip per failure. - for (const n of nodes) { - if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true"); - } - } -} - -window.addEventListener("message", (event: MessageEvent) => { - const msg = event.data; - if (msg?.type !== "render") return; - body.innerHTML = msg.html; - updateAskLabel(); // new content clears any selection → reset the adaptive label - // F11 (PUC-1/7): disable edit controls on a non-authorable doc (reading stays on). - const authorable = msg.authorable !== false; - if (pinEl) pinEl.disabled = !authorable; - if (askEl) askEl.disabled = !authorable; - const on = msg.mode === "on"; - if (annotationsEl) annotationsEl.checked = on; - // #46: Accept all shows only with ≥2 pending proposals, on an authorable doc, - // in the annotated (on) state — a single proposal is just a ✓ in place. - if (acceptAllEl) acceptAllEl.hidden = !on || !authorable || (msg.summary?.proposals ?? 0) < 2; - // 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?.removed ?? 0} ` + - `${msg.summary?.proposals ?? 0} proposal${(msg.summary?.proposals ?? 0) === 1 ? "" : "s"}`; - } - void renderMermaid(); -}); diff --git a/package.json b/package.json index 5f927ce..76f3d68 100644 --- a/package.json +++ b/package.json @@ -109,8 +109,8 @@ "icon": "$(git-compare)" }, { - "command": "cowriting.showTrackChangesPreview", - "title": "Open Cowriting Review Panel", + "command": "cowriting.openReviewPreview", + "title": "Open Cowriting Review Preview", "category": "Cowriting" }, { @@ -252,7 +252,7 @@ "group": "navigation@4" }, { - "command": "cowriting.showTrackChangesPreview", + "command": "cowriting.openReviewPreview", "when": "editorLangId == markdown", "group": "navigation@9" } @@ -264,14 +264,14 @@ "group": "1_cowriting@1" }, { - "command": "cowriting.showTrackChangesPreview", + "command": "cowriting.openReviewPreview", "when": "resourceLangId == markdown", "group": "1_cowriting@3" } ], "explorer/context": [ { - "command": "cowriting.showTrackChangesPreview", + "command": "cowriting.openReviewPreview", "when": "resourceLangId == markdown", "group": "navigation@9" } @@ -330,10 +330,10 @@ }, "keybindings": [ { - "command": "cowriting.showTrackChangesPreview", + "command": "cowriting.reviewChanges", "key": "ctrl+alt+r", "mac": "cmd+alt+r", - "when": "editorLangId == markdown" + "when": "editorLangId == markdown && cowriting.isCoediting" }, { "command": "cowriting.edit", diff --git a/src/editFlow.ts b/src/editFlow.ts index 9c18d29..5b83273 100644 --- a/src/editFlow.ts +++ b/src/editFlow.ts @@ -1,28 +1,22 @@ /** * EditFlow — the host edit-turn flow (F11/F12), extracted from the review * webview controller ahead of its sunset (native-surfaces migration, spec - * §6.10). Owns the instruction prompt + progress-wrapped "ask Claude" gesture - * (INV-10 gate, INV-8 host-only LLM surface), and the low-level turn→proposal(s) - * cut (`runEditAndPropose`, INV-39/40). Never mutates the document directly — - * every turn lands as one or more F4 proposals via `ProposalController.propose` - * (INV-10). `runEditAndPropose` is reachable from the review webview (via this - * class's `askClaude`) and, from Task 6 on, the thread controller - * (`ThreadController.runMakeEdit`) — vscode-API-only, no webview state. The - * `cowriting.editDocument` command lives in extension.ts (Finding-1 fix) so it - * can hand off to `ThreadController.askClaude()`. + * §6.10). Owns the low-level turn→proposal(s) cut (`runEditAndPropose`, + * INV-39/40): never mutates the document directly — every turn lands as one or + * more F4 proposals via `ProposalController.propose` (INV-10). Reachable, from + * Task 6 on, from the thread controller (`ThreadController.runMakeEdit`) — + * vscode-API-only, no webview state. The `cowriting.editDocument` command lives + * in extension.ts (Finding-1 fix) so it can hand off to + * `ThreadController.askClaude()`. Task 8 deleted this class's own + * `askClaude`/`askEditInstruction` (the review webview's toolbar ask) once + * their only caller — the webview — was sunset alongside them. */ import * as vscode from "vscode"; import type { ProposalController } from "./proposalController"; -import type { AttributionController } from "./attributionController"; -import type { LiveProgressUi } from "./liveProgressUi"; -import type { CoeditingRegistry } from "./coeditingRegistry"; import { diffToBlockHunks } from "./trackChangesModel"; import { buildFingerprint } from "./anchorer"; import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; -/** The exact warning copy for every gated edit gesture (INV-10). */ -const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first."; - /** * F11: a host edit turn (selection/document text + instruction → rewrite). * Injectable for tests. #60: accepts optional turn options (onProgress/signal); @@ -42,100 +36,13 @@ export class EditFlow implements vscode.Disposable { const { runEditTurn } = await import("./liveTurn"); return runEditTurn(instruction, text, opts); }; - /** - * LEGACY (Task 6, spec §6.10): the instruction prompt used to be the - * multi-line split-below input webview (`editInstructionInput.ts`, deleted). - * The real interactive ask is now ThreadController.askClaude — a focused - * comment box (D19); `cowriting.editSelection` routes there directly. This - * field's only remaining production caller is the `cowriting.editDocument` - * command below (via `askClaude`), and the review-webview's toolbar ask — - * both die fully in Task 8. Rejects by default so any surviving real call - * fails loudly instead of silently invoking a webview that no longer - * exists; host E2E stub this field directly (mirrors `editTurn`). - */ - askEditInstruction: (header: string) => Promise = (header) => - Promise.reject( - new Error(`Cowriting: the "${header}" instruction input was removed — use ✦ Ask Claude instead.`), - ); /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ private turnSeq = 0; private nextTurnSeq(): number { return ++this.turnSeq; } - constructor( - private readonly proposals: ProposalController, - private readonly attribution: AttributionController, - private readonly liveProgressUi: LiveProgressUi, - private readonly registry: CoeditingRegistry, - ) { - // Code-review fix (Finding 1, session native-surfaces-exec): the - // `cowriting.editDocument` command used to be registered here, calling - // `this.askClaude(doc, {kind:"document"})` — which prompts via the (now - // rejecting-stub) `askEditInstruction`, a silent dead end. That command is now - // registered in extension.ts, AFTER ThreadController exists, and hands off to - // `ThreadController.askClaude()` (the comments-first ask, D19) instead. This - // class's `askClaude`/`askEditInstruction` remain: the review webview's - // toolbar ask (`trackChangesPreview.ts`) still delegates to them, slated for - // deletion alongside that webview in Task 8. - } - - /** - * F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret - * surface out of the sealed webview, INV-8/35), run the edit turn, and surface - * the result as F4 proposal(s). UI wrapper around `runEditAndPropose`. As of the - * Finding-1 fix, its only production caller is the review webview's `askClaude` - * toolbar intent (either scope) — `cowriting.editDocument`/`cowriting.editSelection` - * now delegate to `ThreadController.askClaude()` instead (D19). Both die together - * in Task 8 when the webview is sunset. - */ - async askClaude(document: vscode.TextDocument, target: EditTarget): Promise { - // INV-10: the choke point for the webview's askClaude toolbar intent (either - // scope) — a non-entered doc gets the warning, not a turn. - if (!this.registry.isCoediting(document.uri)) { - void vscode.window.showWarningMessage(NOT_COEDITING_WARNING); - return; - } - // Both scopes use the same multi-line split-below webview box; only the header - // (and the downstream proposal logic) differs. For a selection the document - // above keeps the selection highlighted while the box is open. - const header = - target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection"; - const instruction = await this.askEditInstruction(header); - if (!instruction) return; - try { - const ids = await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: "Cowriting: asking Claude…", - cancellable: true, - }, - async (progress, token) => { - const ui = this.liveProgressUi.begin(instruction, progress, token); - try { - return await this.runEditAndPropose(document, target, instruction, { - onProgress: ui.onProgress, - signal: ui.signal, - }); - } catch (err) { - // #60 (INV-47): a user cancel proposes nothing (the benign empty path). - if (token.isCancellationRequested) return [] as string[]; - throw err; - } - }, - ); - if (ids.length === 0) { - void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes."); - } else { - void vscode.window.showInformationMessage( - `Cowriting: Claude proposed ${ids.length} edit${ids.length === 1 ? "" : "s"} — review ✓/✗ in the preview.`, - ); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`); - } - } + constructor(private readonly proposals: ProposalController) {} /** * F11/F12 (INV-35/39): run one host edit turn and record the result as F4 diff --git a/src/editorProposalController.ts b/src/editorProposalController.ts index b9489a3..379f4d4 100644 --- a/src/editorProposalController.ts +++ b/src/editorProposalController.ts @@ -216,19 +216,33 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL } } - /** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */ + /** + * CodeLensProvider (Task 8, PUC-2 wording): a `✓ Keep` / `✗ Reject` pair above + * each applied block, plus — when ≥2 proposals are pending on the doc — a + * top-of-file `✓ Keep all (N)` / `✗ Reject all` pair routed directly at the + * batch commands (no QuickPick detour for the common "review is done" case). + */ provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { if (document.languageId !== "markdown") return []; if (!this.registry.isCoediting(document.uri)) return []; const key = this.proposals.keyFor(document); + const applied = this.proposals + .listProposals(document) + .filter((v) => v.anchorStart !== null && this.proposals.isApplied(key, v.id)); const lenses: vscode.CodeLens[] = []; - for (const v of this.proposals.listProposals(document)) { - if (v.anchorStart === null || !this.proposals.isApplied(key, v.id)) continue; - const pos = document.positionAt(v.anchorStart); + if (applied.length >= 2) { + const top = new vscode.Range(0, 0, 0, 0); + lenses.push( + new vscode.CodeLens(top, { title: `✓ Keep all (${applied.length})`, command: "cowriting.acceptAllProposals" }), + new vscode.CodeLens(top, { title: "✗ Reject all", command: "cowriting.rejectAllProposals" }), + ); + } + for (const v of applied) { + const pos = document.positionAt(v.anchorStart!); const line = new vscode.Range(pos.line, 0, pos.line, 0); lenses.push( - new vscode.CodeLens(line, { title: "Accept ▾", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }), - new vscode.CodeLens(line, { title: "Reject ▾", command: "cowriting.proposalRejectMenu", arguments: [v.id] }), + new vscode.CodeLens(line, { title: "✓ Keep", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }), + new vscode.CodeLens(line, { title: "✗ Reject", command: "cowriting.proposalRejectMenu", arguments: [v.id] }), ); } return lenses; diff --git a/src/extension.ts b/src/extension.ts index c9877d9..591b484 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,7 +11,6 @@ import { GlobalSidecarStore } from "./globalSidecarStore"; import { SidecarRouter } from "./sidecarRouter"; import { DiffViewController } from "./diffViewController"; import { GitBaselineAdapter } from "./gitBaseline"; -import { TrackChangesPreviewController } from "./trackChangesPreview"; import { EditFlow } from "./editFlow"; import { LiveProgressUi } from "./liveProgressUi"; import { EditorProposalController } from "./editorProposalController"; @@ -29,7 +28,6 @@ export interface CowritingApi { proposalController: ProposalController; versionGuard: VersionGuard; diffViewController: DiffViewController; - trackChangesPreviewController: TrackChangesPreviewController; editFlow: EditFlow; sidecarRouter: SidecarRouter; liveProgressUi: LiveProgressUi; @@ -160,8 +158,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry); context.subscriptions.push(attributionController); - // --- F4: propose/accept (Feature #12) — constructed before the preview so F10 - // can route ✓/✗ through it --- + // --- F4: propose/accept (Feature #12) --- const proposalController = new ProposalController( sidecarRouter, attributionController, @@ -174,10 +171,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef // --- F11/F12 (native-surfaces migration, spec §6.10): the edit flow — the // `cowriting.editDocument` command, the instruction-prompt/progress-wrapped // "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut - // (`runEditAndPropose`, INV-39/40). Constructed BEFORE the review preview (which - // delegates to it) and BEFORE the thread controller, which now consumes it too - // (Task 6: the comment-loop's "make this edit" offer calls runEditAndPropose). --- - const editFlow = new EditFlow(proposalController, attributionController, liveProgressUi, coeditingRegistry); + // (`runEditAndPropose`, INV-39/40). Constructed BEFORE the thread controller, + // which consumes it too (Task 6: the comment-loop's "make this edit" offer + // calls runEditAndPropose). --- + const editFlow = new EditFlow(proposalController); context.subscriptions.push(editFlow); // --- F2/F10 (Task 6, D19/D10/D8): comments-first ask + the comment→reply→ @@ -186,20 +183,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry, editFlow, liveProgressUi); context.subscriptions.push(threadController); - // --- 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 (reads F3 spans), proposals (routes - // F4 accept/reject from the webview ✓/✗), and editFlow (delegates the webview's - // askClaude toolbar intent to it). - const trackChangesPreviewController = new TrackChangesPreviewController( - diffViewController, - context.extensionUri, - attributionController, - proposalController, - editFlow, - ); - context.subscriptions.push(trackChangesPreviewController); - // --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer, // decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). --- const editorProposalController = new EditorProposalController( @@ -211,10 +194,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef context.subscriptions.push(editorProposalController); // #46 (INV-42): accept every pending proposal on the active doc in one gesture - // (also reachable from the preview toolbar's "Accept all" button, which routes - // through its own webview-facing acceptAll). Reuses the batched F4 seam + - // reports applied-vs-skipped (native-surfaces migration: routes directly to - // ProposalController, no preview-controller indirection). + // (also reachable from the top-of-file "Keep all (N)" CodeLens, Task 8 PUC-2). + // Reuses the batched F4 seam + reports applied-vs-skipped, routing directly to + // ProposalController (native-surfaces migration: no preview-controller + // indirection). context.subscriptions.push( vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => { const doc = vscode.window.activeTextEditor?.document; @@ -223,7 +206,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef return; } const { applied, skipped } = await proposalController.acceptAllProposals(doc); - trackChangesPreviewController.refresh(doc); if (applied === 0 && skipped === 0) return; const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : ""; void vscode.window.showInformationMessage( @@ -242,7 +224,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef return; } const { reverted } = await proposalController.rejectAll(doc); - trackChangesPreviewController.refresh(doc); if (reverted > 0) { void vscode.window.showInformationMessage( `Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`, @@ -333,11 +314,13 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ); // Code-review fix (Finding 1, session native-surfaces-exec): `cowriting.editDocument` - // used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompts via - // `askEditInstruction` — a rejecting stub since Task 6 sunset the input webview - // (spec §6.10). Reached with no selection (the common case for `cowriting.edit`), - // that was a SILENT dead end: the reject fired before EditFlow.askClaude's own try/ - // catch and was `void`'d, so no toast, no thread, nothing. This command now resolves + // used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompted via + // `askEditInstruction` — a rejecting stub after Task 6 sunset the input webview + // (spec §6.10; both `askClaude`/`askEditInstruction` were deleted from EditFlow in + // Task 8 once their only caller, the review webview, died too). Reached with no + // selection (the common case for `cowriting.edit`), that was a SILENT dead end: the + // reject fired before EditFlow.askClaude's own try/catch and was `void`'d, so no + // toast, no thread, nothing. This command now resolves // its target document exactly as before (a tab's clicked URI, falling back to the // active editor), focuses it, and hands off to the SAME comments-first ask as // `cowriting.editSelection` — `ThreadController.askClaude()` top-anchors a @@ -386,6 +369,33 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef }), ); + // Task 8 (native-surfaces migration, spec §6.10): the minimal gateway replacing + // the sunset review-webview's #41 right-click entries. Resolves the clicked + // doc (or falls back to the active editor, mirroring editDocument/#41), enters + // coediting if not already (so a fresh doc gets a baseline to review against — + // no-op if already entered or not authorable), then hands off to VS Code's + // OWN "Open Preview to the Side" — the built-in preview IS the review surface + // now (Task 7's annotations render inside it). + context.subscriptions.push( + vscode.commands.registerCommand("cowriting.openReviewPreview", async (uri?: vscode.Uri) => { + const doc = uri + ? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ?? + (await vscode.workspace.openTextDocument(uri)) + : vscode.window.activeTextEditor?.document; + if (!doc || doc.languageId !== "markdown") { + void vscode.window.showWarningMessage("Cowriting: open a Markdown document to review it."); + return; + } + if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) { + await vscode.window.showTextDocument(doc); + } + if (!coeditingRegistry.isCoediting(doc.uri) && isAuthorable(doc.uri.scheme)) { + await vscode.commands.executeCommand("cowriting.coeditDocument"); + } + await vscode.commands.executeCommand("markdown.showPreviewToSide", doc.uri); + }), + ); + // Render threads + attributions for already-open editors, and on future opens. // INV-10: gated on the registry too — belt-and-suspenders with the per-controller // gates (renderAll/loadAll already early-return), but keeps the intent explicit @@ -456,7 +466,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef proposalController, versionGuard, diffViewController, - trackChangesPreviewController, editFlow, sidecarRouter, liveProgressUi, diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts deleted file mode 100644 index 9c1b1e5..0000000 --- a/src/trackChangesPreview.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** - * TrackChangesPreviewController — F7 vscode layer (spec §6.2/§6.4). Owns one - * sealed webview panel per markdown document, beside the source editor. On open / - * debounced edit / F6 baseline-epoch change it reads the baseline (from the - * reused DiffViewController) + the live buffer, runs the pure render engine, and - * posts the HTML. Pure read-only: never mutates the document, sidecar, or - * baseline (INV-20). The webview is sealed: local assets only, strict CSP, - * per-load nonce, no network (INV-21). - */ -import * as path from "node:path"; -import { randomBytes } from "node:crypto"; -import * as vscode from "vscode"; -import type { DiffViewController } from "./diffViewController"; -import type { AttributionController } from "./attributionController"; -import type { ProposalController } from "./proposalController"; -import { renderReview, renderPlain, diffBlocks, landedTextOf, type BlockOp } from "./trackChangesModel"; -import { isAuthorable } from "./workspacePath"; -import type { EditFlow, EditTarget } from "./editFlow"; - -const VIEW_TYPE = "cowriting.trackChangesPreview"; -const DEBOUNCE_MS = 150; - -/** - * Inbound webview→host messages (intent only — the sealed webview never mutates, - * INV-21/35). F10 carried the annotations toggle + ✓/✗ proposal decisions; F11 - * adds the toolbar intents (pin baseline / ask Claude). - */ -type ToolbarMsg = - | { type: "setMode"; mode: "on" | "off" } - | { type: "accept"; proposalId: string } - | { type: "reject"; proposalId: string } - | { type: "pinBaseline" } - | { type: "askClaude"; scope: "document" } - | { type: "askClaude"; scope: "selection"; start: number; end: number } - | { type: "acceptAll" } - | { type: "rejectAll" }; - -export class TrackChangesPreviewController implements vscode.Disposable { - private readonly disposables: vscode.Disposable[] = []; - private readonly panels = new Map(); - private readonly lastModel = new Map(); - private readonly debounces = 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, - private readonly editFlow: EditFlow, - ) { - this.disposables.push( - // F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri; - // the palette / keybinding pass nothing → fall back to the active editor. - // #41: the explorer/tab right-click also passes the clicked Uri, which may - // not be an open document yet — open it so we preview the clicked file, not - // whatever happens to be the active editor. - vscode.commands.registerCommand("cowriting.showTrackChangesPreview", async (uri?: vscode.Uri) => { - if (uri) { - const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()); - this.show(open ?? (await vscode.workspace.openTextDocument(uri))); - return; - } - 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); - }), - this.statusItem, - ); - this.statusItem.command = "cowriting.showTrackChangesPreview"; - } - - private isMarkdown(document: vscode.TextDocument): boolean { - return document.languageId === "markdown"; - } - - /** Open or reveal the preview for a markdown document (PUC-1). */ - show(document: vscode.TextDocument | undefined): void { - if (!document || !this.isMarkdown(document)) { - void vscode.window.showWarningMessage( - "Cowriting: open a Markdown document to use the track-changes preview (F6 covers other files).", - ); - return; - } - const key = document.uri.toString(); - const existing = this.panels.get(key); - if (existing) { - existing.reveal(vscode.ViewColumn.Beside); - this.refresh(document); - return; - } - const name = path.basename(document.uri.path) || "untitled"; - const panel = vscode.window.createWebviewPanel( - VIEW_TYPE, - `Review: ${name}`, - { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, - { - enableScripts: true, - retainContextWhenHidden: false, - localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "out", "media")], - }, - ); - panel.webview.html = this.shellHtml(panel.webview); - panel.onDidDispose( - () => { - 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, - ); - // F10/F11: the webview posts the annotations toggle, ✓/✗ proposal decisions, - // and (F11) the toolbar intents (pin baseline / ask Claude) back to the host. - panel.webview.onDidReceiveMessage( - (m: ToolbarMsg) => this.handleWebviewMessage(document, m), - 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); - } - - /** - * Route an inbound webview intent through the existing seams (INV-35): the - * annotations toggle + ✓/✗ proposal decisions (F10) and the toolbar gestures - * (F11). Pin targets the PREVIEWED document (`DiffViewController.pin`), not the - * active editor — the preview knows its bound doc (§6.7). - */ - private handleWebviewMessage(document: vscode.TextDocument, m: ToolbarMsg): void { - const key = document.uri.toString(); - 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) { - void this.proposals.rejectByIdInPlace(this.proposals.keyFor(document), m.proposalId).then(() => this.refresh(document)); - } else if (m?.type === "pinBaseline") { - // F6 baseline store re-render arrives via the onDidChangeBaseline subscription. - this.diffView.pin(document); - } else if (m?.type === "askClaude") { - const target: EditTarget = - m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" }; - void this.editFlow.askClaude(document, target); - } else if (m?.type === "acceptAll") { - // #46 (INV-42): batch-accept every pending proposal on this doc, then report. - void this.acceptAll(document); - } else if (m?.type === "rejectAll") { - void this.rejectAll(document); - } - } - - /** - * #46 (INV-42): apply every pending proposal on the document through the F4 - * accept seam (orphan-skip) and report the applied-vs-skipped tally. No - * confirmation dialog — VS Code undo restores (parity with single accept). - * Public so the `cowriting.acceptAllProposals` command can reach it for the - * active doc (not only the webview button). - */ - async acceptAll(document: vscode.TextDocument): Promise { - const { applied, skipped } = await this.proposals.acceptAllProposals(document); - this.refresh(document); - if (applied === 0 && skipped === 0) return; - const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : ""; - void vscode.window.showInformationMessage( - `Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`, - ); - } - - /** #64 (INV-53): revert every pending proposal on the document; report the count. */ - async rejectAll(document: vscode.TextDocument): Promise { - const { reverted } = await this.proposals.rejectAll(document); - this.refresh(document); - if (reverted > 0) { - void vscode.window.showInformationMessage( - `Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`, - ); - } - } - - private onEdit(document: vscode.TextDocument): void { - const key = document.uri.toString(); - if (!this.panels.has(key)) return; - const pending = this.debounces.get(key); - if (pending) clearTimeout(pending); - this.debounces.set( - key, - setTimeout(() => { - this.debounces.delete(key); - this.refresh(document); - }, DEBOUNCE_MS), - ); - } - - private refreshByUri(uri: string): void { - const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); - if (doc) this.refresh(doc); - } - - /** Recompute the model + post HTML to the panel for its current mode (no-op if no panel). */ - refresh(document: vscode.TextDocument): void { - const key = document.uri.toString(); - const panel = this.panels.get(key); - if (!panel) return; - const 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); - // F11 (PUC-1/7): edit controls are inert on a non-authorable doc (reading stays allowed). - const authorable = isAuthorable(document.uri.scheme); - if (mode === "off") { - void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current), authorable }); - return; - } - const spans = this.attribution.spansFor(document); - const proposals = this.proposals.listProposals(document); - // F12/#64 (INV-50): count added/removed against the LANDED text (current minus - // pending proposals), matching the body — a pending change shows once, as a - // proposal, and is not also tallied as a landed add/remove. - const landedOps = diffBlocks(baselineText, landedTextOf(current, proposals)); - const summary = { - added: landedOps.filter((o) => o.kind === "added").length, - removed: landedOps.filter((o) => o.kind === "removed").length, - proposals: proposals.length, - }; - void panel.webview.postMessage({ - type: "render", - mode, - html: renderReview(baselineText, current, spans, proposals, { pinned: baseline?.reason === "pinned" }), - epoch: this.epochLabel(baseline), - summary, - authorable, - }); - } - - /** 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 "not coediting (no baseline yet)"; - const time = new Date(baseline.capturedAt).toLocaleTimeString(); - switch (baseline.reason) { - case "head": - return `HEAD ${time}`; - case "pinned": - return `reviewed ${time}`; - default: - return `entered ${time}`; - } - } - - private shellHtml(webview: vscode.Webview): string { - const nonce = randomBytes(16).toString("base64"); - const scriptUri = webview.asWebviewUri( - vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.js"), - ); - const styleUri = webview.asWebviewUri( - vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.css"), - ); - // Sealed CSP (INV-21): no network. 'unsafe-inline' style is required only for - // mermaid's dynamically injected