10 Commits

Author SHA1 Message Date
Ben Stull 0f5bc1b4ce F10 SLICE-4: manual smoke checklist + README F10 section (#29)
Adds docs/MANUAL-SMOKE-F10.md (clean editor → edit → propose → ✓/✗ →
Annotations toggle → status-bar PUC-6 → theming → cleanliness) following the
F7/F9 smoke format. Adds the F10 "write left / review right" section to the
README feature list and notes F6's two-pane diff and F9's authorship view are
retained only as data layers, not separate user surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:32:46 -07:00
Ben Stull cdeb41ede4 F10 SLICE-4: host E2E for interactive review (open/toggle/propose→accept→reject/clean-editor/hidden-F6/status); update obsolete F9/toggle suites (#29)
Adds test/e2e/suite/f10Review.test.ts covering the F10 flow: open preview
(mode "on", fresh baseline all-unchanged), type → cw-by-human span, propose →
cw-proposal block with ✓/✗, accept → lands + baseline advances + block clears,
reject → vanishes + doc untouched, toggle off → renderPlain has no cw- marks,
status-bar PUC-6 (indicator with no panel, hidden once opened), and the
clean-editor INV-32 facts (retired toggleAttribution, F6 ctrl+alt+d hidden).

Rewrites the obsolete F9 authorship-mode test as an F10 review test
(cw-by-claude in the on-state render; getMode defaults to "on"). Updates
attribution.test.ts (drops the retired isVisible/toggleAttribution toggle,
asserts the toggle is gone) and noWorkspace.test.ts (toggleAttribution +
acceptProposal/rejectProposal are retired, preview-only — INV-32).

Honesty fix in the status-bar seam: hideStatus() clears statusItem.text so
statusText() reports undefined when the indicator is hidden (it previously
returned its stale last value after a panel opened).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:31:54 -07:00
Ben Stull 42d0ec155e F10 SLICE-3: rename preview panel title to 'Review' (#29)
Code-quality polish: the webview panel title read 'Track changes: <name>' but
F10 makes the preview the single review surface. Match the 'Open Review Preview'
command rename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:24:18 -07:00
Ben Stull 604c558287 F10 SLICE-3: wire ProposalController into the review preview (#29)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:20:00 -07:00
Ben Stull 28c5e9d334 F10 SLICE-3: webview on/off switch + ✓/✗ click→postMessage + proposal CSS (#29)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:20:00 -07:00
Ben Stull f8b36c3452 F10 SLICE-3: preview on/off mode; take ProposalController; renderReview path + accept/reject routing (#29)
Includes the PUC-6 status-bar indicator (updateStatus + show/dispose hooks)
since the constructor subscription references it (one file, no intermediate
broken build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:19:52 -07:00
Ben Stull fe45569218 F10 SLICE-3: ProposalController.listProposals + onDidChangeProposals + keyFor (#29)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:17:26 -07:00
Ben Stull bda394ea4e F10 SLICE-3: remove superseded public renderAuthorship (salvaged into colorByAuthor) (#29)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:16:57 -07:00
Ben Stull bcacbf062d F10 SLICE-2: fix renderReview author-coloring for duplicate blocks; tidy renderReviewOp + escape proposal id (#29)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:13:54 -07:00
Ben Stull d59f22b22f F10 SLICE-2: add ProposalView + renderReview combined on-state render (#29)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:09:45 -07:00
13 changed files with 652 additions and 222 deletions
+26 -1
View File
@@ -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.
+58
View File
@@ -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 (`<ins>` / `cw-by-human`), the old word struck (`<del>` / `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.
+19 -7
View File
@@ -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; }
+30 -23
View File
@@ -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<HTMLButtonElement>(".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<HTMLElement>(".cw-actions button");
if (!btn) return;
const block = btn.closest<HTMLElement>(".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<RenderMessage>) => {
const msg = event.data;
if (msg?.type !== "render") return;
body.innerHTML = msg.html;
for (const seg of segs) seg.classList.toggle("cw-seg-on", seg.dataset.mode === msg.mode);
const authorship = msg.mode === "authorship";
header.hidden = authorship;
summary.hidden = authorship;
legend.hidden = !authorship;
if (authorship) {
const parts: string[] = [];
if (msg.legend?.claude) parts.push('<span class="cw-by-claude cw-swatch">Claude</span>');
if (msg.legend?.human) parts.push('<span class="cw-by-human cw-swatch">You</span>');
legend.innerHTML = parts.join(" ") || "no attribution yet";
} else {
header.textContent = `Track changes since ${msg.epoch ?? ""}`;
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 =
`<span class="cw-add">+${(msg.summary?.added ?? 0) + (msg.summary?.changed ?? 0)}</span> ` +
`<span class="cw-del">${(msg.summary?.removed ?? 0) + (msg.summary?.changed ?? 0)}</span>`;
`<span class="cw-add">+${msg.summary?.added ?? 0}</span> ` +
`<span class="cw-del">${msg.summary?.removed ?? 0}</span> ` +
`<span class="cw-prop">${msg.summary?.proposals ?? 0} proposal${(msg.summary?.proposals ?? 0) === 1 ? "" : "s"}</span>`;
}
void renderMermaid();
});
+9 -7
View File
@@ -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.
+31
View File
@@ -18,6 +18,7 @@ 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 {
@@ -44,6 +45,9 @@ export class ProposalController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocState>(); // keyed by docPath
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,
@@ -52,11 +56,16 @@ export class ProposalController implements vscode.Disposable {
private readonly guard: VersionGuard,
) {
this.disposables.push(this.statusItem);
this.disposables.push(this.onDidChangeProposalsEmitter);
this.disposables.push(
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);
}
@@ -64,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);
@@ -181,6 +211,7 @@ export class ProposalController implements vscode.Disposable {
}
}
this.renderStatus(state);
this.fireChanged(document);
}
/** Shared-watcher entry point (extension.ts): a sidecar changed externally. */
+71 -33
View File
@@ -367,39 +367,6 @@ export function colorByAuthor(
return sentinelsToSpans(render(injected));
}
/**
* Pure authorship render (INV-26/28): the CURRENT text with each F3-attributed
* span colored by author. Prose blocks get inline `<span class="cw-by-*">`;
* code/mermaid fences stay ATOMIC (INV-27) — an overlapping span yields a
* block-level author badge, never inner sentinels. Deterministic.
*/
export function renderAuthorship(
currentText: string,
spans: AuthorSpan[],
opts: RenderOptions = {},
): string {
const render = opts.render ?? defaultRender;
const safe = (src: string): string => {
try {
return render(src);
} catch (err) {
return chip(err instanceof Error ? err.message : String(err));
}
};
return splitBlocksWithRanges(currentText)
.map((b) => {
const overlapping = spans.filter((s) => s.end > b.start && s.start < b.end);
if (b.type !== "prose") {
const badge = authorBadge(new Set(overlapping.map((s) => s.author)));
const inner = safe(b.raw);
if (!badge) return `<div class="cw-blk">${inner}</div>`;
return `<div class="cw-blk ${badge.cls}"><span class="cw-badge">${badge.label}</span>${inner}</div>`;
}
return `<div class="cw-blk">${colorByAuthor(b.raw, b.start, overlapping, safe)}</div>`;
})
.join("\n");
}
/** 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;
@@ -410,6 +377,77 @@ export function renderPlain(currentText: string, opts: RenderOptions = {}): stri
}
}
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);
} catch (err) {
return chip(err instanceof Error ? err.message : String(err));
}
};
const unanchored = p.anchorStart === null ? " cw-proposal-unanchored" : "";
const before = p.replaced ? `<del class="cw-del">${safe(p.replaced)}</del>` : "";
const after = `<ins class="cw-add">${safe(p.replacement)}</ins>`;
const actions =
`<span class="cw-actions">` +
`<button class="cw-accept" data-action="accept">✓</button>` +
`<button class="cw-reject" data-action="reject">✗</button>` +
`</span>`;
return `<div class="cw-proposal${unanchored}" data-proposal-id="${md.utils.escapeHtml(p.id)}">${actions}${before}${after}</div>`;
}
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 `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}">${colored(op.block.raw)}</div>`;
}
/**
* 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). */
export function renderTrackChanges(
baselineText: string,
+83 -39
View File
@@ -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<string, vscode.WebviewPanel>();
private readonly lastModel = new Map<string, BlockOp[]>();
private readonly debounces = new Map<string, NodeJS.Timeout>();
/** F9: per-panel view mode — track-changes (default) or authorship. */
private readonly mode = new Map<string, "changes" | "authorship">();
/** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */
private readonly mode = new Map<string, "on" | "off">();
/** 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 {
</head>
<body>
<div id="cw-header">
<div id="cw-mode" role="group">
<button id="cw-mode-changes" class="cw-seg cw-seg-on" data-mode="changes">Track changes</button>
<button id="cw-mode-authorship" class="cw-seg" data-mode="authorship">Authorship</button>
</div>
<span id="cw-epoch">Track changes</span>
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
<span id="cw-epoch">Review</span>
<span id="cw-summary"></span>
<span id="cw-legend" hidden></span>
<span id="cw-legend"></span>
</div>
<div id="cw-body"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
@@ -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);
@@ -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 () => {
+5 -6
View File
@@ -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)");
});
});
+20 -26
View File
@@ -14,16 +14,20 @@ async function getApi(): Promise<CowritingApi> {
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, /<span class="cw-by-claude">/, "landed Claude prose is author-colored in the on-state render");
});
});
+238
View File
@@ -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<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal");
return api;
}
/** 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<string> {
const start = doc.getText().indexOf(target);
assert.ok(start >= 0, `fixture contains "${target}"`);
const id = await vscode.commands.executeCommand<string>("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, /<span class="cw-by-human">/, "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() };
}
+53 -77
View File
@@ -1,5 +1,5 @@
import { describe, it, test, expect } from "vitest";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, renderAuthorship, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
describe("splitBlocks", () => {
it("splits prose paragraphs on blank lines, dropping empties", () => {
@@ -159,82 +159,6 @@ 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.");
});
it("wraps a single Claude span inline", () => {
const text = "The cat sat on the mat.\n";
const html = renderAuthorship(text, [spanAt(text, "cat sat", "claude")]);
expect(html).toContain('<span class="cw-by-claude">cat sat</span>');
});
it("marks two authors in one paragraph at exact boundaries", () => {
const text = "Alpha beta gamma.\n";
const html = renderAuthorship(text, [
spanAt(text, "Alpha", "human"),
spanAt(text, "gamma", "claude"),
]);
expect(html).toContain('<span class="cw-by-human">Alpha</span>');
expect(html).toContain('<span class="cw-by-claude">gamma</span>');
});
it("handles two ADJACENT spans (one's end == next's start) in order", () => {
const text = "ONETWO\n";
const html = renderAuthorship(text, [
{ start: 0, end: 3, author: "human" }, // ONE
{ start: 3, end: 6, author: "claude" }, // TWO
]);
expect(html).toContain('<span class="cw-by-human">ONE</span><span class="cw-by-claude">TWO</span>');
});
it("clips a span to its block (does not bleed across blocks)", () => {
const text = "Para one.\n\nPara two.\n";
const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
expect(html).toContain('<span class="cw-by-claude">Para one.</span>');
expect(html).toContain('<span class="cw-by-claude">Para two.</span>');
});
it("a code fence overlapping a span gets a block badge, NOT inner sentinels (atomic)", () => {
const text = "```js\nconst x = 1;\n```\n";
const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
expect(html).toContain("cw-by-claude");
expect(html).toContain("cw-badge");
expect(html).not.toMatch(/[\uE000-\uE003]/); // no sentinel leaked
expect(html).toContain("const x = 1;");
});
it("a mermaid fence authored by Claude renders as a diagram with a badge", () => {
const text = "```mermaid\ngraph TD; A-->B;\n```\n";
const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
expect(html).toContain('pre class="mermaid"');
expect(html).toContain("cw-by-claude");
expect(html).toContain("cw-badge");
});
it("never leaks a raw sentinel into prose output", () => {
const text = "Some mixed text here.\n";
const html = renderAuthorship(text, [spanAt(text, "mixed", "claude")]);
expect(html).not.toMatch(/[\uE000-\uE003]/);
});
it("is deterministic", () => {
const text = "Stable input paragraph.\n";
const spans: AuthorSpan[] = [spanAt(text, "input", "claude")];
expect(renderAuthorship(text, spans)).toBe(renderAuthorship(text, spans));
});
});
describe("colorByAuthor", () => {
test("colorByAuthor wraps human-authored prose in cw-by-human spans", () => {
const raw = "hello world";
@@ -257,6 +181,58 @@ describe("renderPlain", () => {
});
});
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(/<ins>[^<]*world[^<]*<\/ins>|cw-by-human/);
});
test("renderReview: deletion since baseline renders struck del/cw-del", () => {
const html = renderReview("hello world", "hello", [], []);
expect(html).toMatch(/<del>|cw-del/);
});
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(/<del[^>]*>[^<]*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 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-");
});
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);
});
});
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {