feat: baseline router — git HEAD + snapshot per INV-7/D13/D14; retire machine-landing advance (spec §6.4)

Task 2 of the native-surfaces migration plan. GitBaselineAdapter resolves a
document's HEAD blob via the built-in vscode.git extension (hardened with a
.git/logs/HEAD watch that nudges repo.status(), since the git extension's own
watcher doesn't reliably notice out-of-band commits on non-workspace-folder
repos in the E2E host). DiffViewController is reworked into a baseline router:
head mode (git-tracked, never persisted, re-read on commit) vs snapshot mode
(captured on CoeditingRegistry entry, re-pinned by "Mark Changes as Reviewed"
— renamed from cowriting.pinDiffBaseline, D14). The shipped machine-landing
baseline advance (#48/INV-18) is retired: a landed Claude edit now stays a
visible change-since-baseline until commit or review (INV-7/D21). Legacy
on-disk reasons ("opened"/"machine-landing") migrate to "entered" via
BaselineStore.normalizeReason on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 07:09:55 -07:00
parent de83757754
commit 447a1170ce
23 changed files with 558 additions and 140 deletions
+3 -3
View File
@@ -85,8 +85,8 @@
"category": "Cowriting"
},
{
"command": "cowriting.pinDiffBaseline",
"title": "Cowriting: Pin Review Baseline to Now",
"command": "cowriting.markReviewed",
"title": "Mark Changes as Reviewed",
"category": "Cowriting"
},
{
@@ -149,7 +149,7 @@
"when": "false"
},
{
"command": "cowriting.pinDiffBaseline",
"command": "cowriting.markReviewed",
"when": "editorLangId == markdown"
},
{
+13 -7
View File
@@ -56,8 +56,11 @@ export class AttributionController implements vscode.Disposable {
/**
* F6 (§6.2/§6.4): the single machine-landing signal. Fired after a real
* (non-no-op) seam apply succeeds. INV-9 makes the seam the sole machine-edit
* ingress, so this is the sole signal — DiffViewController subscribes to
* advance the baseline; no call-site wiring, no future driver can forget it.
* ingress, so this is the sole signal. Native-surfaces migration (INV-7/
* INV-18 retirement, spec §6.4): DiffViewController no longer subscribes
* here to advance the baseline — a landed edit stays a visible
* change-since-baseline until commit (head mode) or "Mark Changes as
* Reviewed" (snapshot mode). Other consumers may still subscribe.
*/
private readonly applyEmitter = new vscode.EventEmitter<{ document: vscode.TextDocument }>();
readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event;
@@ -291,9 +294,10 @@ export class AttributionController implements vscode.Disposable {
);
}
if (ok && opts?.landBaseline !== false) {
// F6 (INV-18): a real machine landing — advance the baseline. F12 (INV-48)
// suppresses this for optimistic apply: the proposed text is in the buffer
// but the change stays PENDING (baseline at pre-proposal) until accept.
// F6 (INV-18, retired as a baseline advance — spec §6.4/INV-7): fire the
// machine-landing signal. F12 (INV-48) suppresses this for optimistic
// apply: the proposed text is in the buffer but the change stays PENDING
// until accept.
this.applyEmitter.fire({ document });
}
return ok;
@@ -302,8 +306,10 @@ export class AttributionController implements vscode.Disposable {
/**
* F12/#64 (INV-51): fire the machine-landing signal WITHOUT applying text — used
* by finalize-in-place, where the proposed text already landed in the buffer via
* optimistic apply (`landBaseline:false`) and accept only needs to advance the
* F6 baseline so the now-accepted change stops reading as pending.
* optimistic apply (`landBaseline:false`) and accept marks the landing so the
* now-accepted change is recorded (INV-7: the baseline itself no longer
* advances on landing — it advances only on commit / "Mark Changes as
* Reviewed").
*/
signalLanded(document: vscode.TextDocument): void {
if (this.isTracked(document)) this.applyEmitter.fire({ document });
+17 -2
View File
@@ -7,11 +7,19 @@
* key is a hash of the document URI (the controller derives it), not a
* workspace-relative path; an untitled buffer has no durable identity and is
* never persisted here (in-memory only — see DiffViewController).
*
* INV-7 (native-surfaces migration, spec §6.4): the baseline is now either the
* git-HEAD blob (never persisted — see GitBaselineAdapter/DiffViewController)
* or a snapshot captured on coediting-entry / re-pinned by "Mark Changes as
* Reviewed". `"opened"`/`"machine-landing"` are legacy reasons from the
* shipped #17/#48 baseline; `normalizeReason` migrates them on load so old
* on-disk baselines keep round-tripping under the new vocabulary.
*/
import * as fs from "node:fs";
import * as path from "node:path";
export type BaselineReason = "opened" | "machine-landing" | "pinned";
export type BaselineReason = "entered" | "pinned" | "head";
type LegacyBaselineReason = BaselineReason | "opened" | "machine-landing";
export interface Baseline {
/** the document URI (the identity the key is derived from) — for round-trip/debug. */
@@ -22,6 +30,12 @@ export interface Baseline {
reason: BaselineReason;
}
/** Migrate legacy on-disk reasons to the current vocabulary (§6.4 INV-7). */
export function normalizeReason(reason: LegacyBaselineReason): BaselineReason {
if (reason === "opened" || reason === "machine-landing") return "entered";
return reason;
}
export class BaselineStore {
/** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */
constructor(private readonly storageDir: string) {}
@@ -34,7 +48,8 @@ export class BaselineStore {
load(key: string): Baseline | null {
const p = this.baselinePath(key);
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, "utf8")) as Baseline;
const raw = JSON.parse(fs.readFileSync(p, "utf8")) as Baseline;
return { ...raw, reason: normalizeReason(raw.reason) };
}
/** Newest epoch wins — overwrite in place, no history (state-not-history, the F3/F4 precedent). */
+121 -38
View File
@@ -1,9 +1,16 @@
/**
* DiffViewController — F6 baseline data layer (spec §6.4/§6.7). Owns the baseline
* lifecycle (initialize at first sight / advance at every machine landing / pin
* on demand) and serves it to F7/F10 via `getBaseline` + the additive
* `onDidChangeBaseline` event. A pure data layer: never mutates the document,
* sidecar, or attribution state (INV-19).
* DiffViewController — the baseline router (native-surfaces migration, spec
* §6.4, INV-7). Resolves each coedited document's review baseline to one of two
* modes: **head** (git-tracked — the baseline is the `HEAD` blob, re-read on
* every commit, never persisted) or **snapshot** (untracked/no-repo — captured
* on coediting-entry, re-pinned on demand by "Mark Changes as Reviewed").
* Baselines are established ONLY for documents the CoeditingRegistry has
* entered (INV-10) — opening a file no longer captures one (the retired
* `ensureBaseline`/#17 behavior). The shipped machine-landing advance (#48,
* INV-18) is retired too: a landed Claude edit stays a visible change-since-
* baseline until the file is committed (head mode) or reviewed (snapshot
* mode) — spec INV-7's note, D21. A pure data layer: never mutates the
* document, sidecar, or attribution state (INV-19).
*
* The F6 two-pane `vscode.diff` *view* (toggle UI + the `cowriting-baseline:`
* virtual document) was removed in #34 once F10 made the rendered preview the
@@ -12,37 +19,57 @@
* The baseline works on ANY text document, not just workspace files — it needs no
* `.threads/` sidecar, only a stable doc identity + a storage home. So it has
* its OWN diffable predicate (any `file:` or `untitled:` doc), decoupled from
* F2's workspace `isTracked`. Persistable docs (`file:`) keep their baseline in
* VS Code's per-extension GLOBAL storage keyed by a hash of the document URI;
* untitled buffers have no durable identity, so their baseline is in-memory
* only (lost on reload) — the same degrade the storage-unavailable path uses.
* F2's workspace `isTracked`. Persistable docs (`file:`) keep their SNAPSHOT
* baseline in VS Code's per-extension GLOBAL storage keyed by a hash of the
* document URI (INV-7: head-mode baselines are storage-free — never
* persisted); untitled buffers have no durable identity, so their baseline is
* in-memory only (lost on reload) — the same degrade the storage-unavailable
* path uses.
*/
import { createHash } from "node:crypto";
import * as vscode from "vscode";
import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore";
import type { GitBaselineAdapter } from "./gitBaseline";
import type { CoeditingRegistry } from "./coeditingRegistry";
export class DiffViewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
/** Source of truth for the baseline, keyed by `document.uri.toString()`. */
private readonly baselines = new Map<string, Baseline>();
/** F7 (additive): fires on every baseline capture (open / advance / pin) so
* the track-changes preview refreshes without polling. Carries the real
* document URI. */
/** Which mode each established document resolved to (INV-7). */
private readonly modes = new Map<string, "head" | "snapshot">();
/** F7 (additive): fires on every baseline capture (establish / head-refresh /
* pin) so the track-changes preview refreshes without polling. Carries the
* real document URI. */
private readonly onDidChangeBaselineEmitter = new vscode.EventEmitter<{ uri: string }>();
readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event;
private storageWarned = false;
constructor(private readonly store: BaselineStore | null) {
constructor(
private readonly store: BaselineStore | null,
private readonly git: GitBaselineAdapter,
registry: CoeditingRegistry,
) {
this.disposables.push(
this.onDidChangeBaselineEmitter,
vscode.commands.registerCommand("cowriting.pinDiffBaseline", () =>
vscode.commands.registerCommand("cowriting.markReviewed", () =>
this.pinCommand(vscode.window.activeTextEditor),
),
// F6 captures a baseline for any diffable doc the moment it is first seen,
// independent of the workspace gate (so "opened" is the open-time text).
vscode.workspace.onDidOpenTextDocument((d) => this.ensureBaseline(d)),
// INV-10: a baseline is established only once its document ENTERS
// coediting; exiting keeps the stored baseline as-is (PUC-7).
registry.onDidChange(({ uri, coediting }) => {
if (!coediting) return;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) void this.establish(doc);
}),
// A commit on a watched repo may advance a head-mode baseline (D13).
git.onDidChangeHead(() => void this.refreshHeadBaselines()),
);
for (const d of vscode.workspace.textDocuments) this.ensureBaseline(d);
// Restore already-coediting documents (e.g. after an extension host reload).
for (const key of registry.list()) {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
if (doc) void this.establish(doc);
}
}
// ---- diffability / identity --------------------------------------------------------
@@ -51,7 +78,7 @@ export class DiffViewController implements vscode.Disposable {
private isDiffable(document: vscode.TextDocument): boolean {
return document.uri.scheme === "file" || document.uri.scheme === "untitled";
}
/** Only `file:` docs have a durable identity to persist a baseline against. */
/** Only `file:` docs have a durable identity to persist a SNAPSHOT baseline against. */
private isPersistable(document: vscode.TextDocument): boolean {
return document.uri.scheme === "file";
}
@@ -64,36 +91,66 @@ export class DiffViewController implements vscode.Disposable {
return createHash("sha256").update(uriKey).digest("hex");
}
// ---- baseline lifecycle (§6.4) -----------------------------------------------------
// ---- baseline lifecycle (§6.4, INV-7) -----------------------------------------------
/** First sight of a diffable doc: load the stored baseline, else capture `opened`. */
ensureBaseline(document: vscode.TextDocument): void {
/**
* Resolve this document's baseline mode and (re)establish its baseline.
* git-tracked (a HEAD blob resolves) → **head** mode, baseline = HEAD, never
* persisted. Otherwise → **snapshot** mode: load a previously-stored
* snapshot, else capture the buffer now (`"entered"`). Called once when a
* document enters coediting (registry.enter) and at startup for documents
* already coediting.
*/
async establish(document: vscode.TextDocument): Promise<void> {
if (!this.isDiffable(document)) return;
const key = this.uriKey(document);
if (this.baselines.has(key)) return;
if (this.store && this.isPersistable(document)) {
try {
const stored = this.store.load(this.storageKey(key));
if (stored) {
this.baselines.set(key, stored);
const head = await this.git.headFor(document.uri);
if (head) {
this.modes.set(key, "head");
this.setBaseline(key, head.text, "head");
return;
}
} catch {
this.warnStorageOnce();
this.modes.set(key, "snapshot");
if (this.store && this.isPersistable(document)) {
const stored = this.tryLoad(key); // existing load path, legacy reasons normalized
if (stored) {
this.baselines.set(key, stored);
this.fire(key);
return;
}
}
this.capture(document, "opened");
this.capture(document, "entered");
}
/** Machine landing (INV-18): re-capture so landed text never shows as a change. */
advance(document: vscode.TextDocument): void {
if (!this.isDiffable(document)) return;
this.capture(document, "machine-landing");
/** The mode a document's baseline resolved to, or undefined before `establish`. */
modeOf(uriString: string): "head" | "snapshot" | undefined {
return this.modes.get(uriString);
}
/** Human pin: baseline := now; the preview's change-marks empty (left = right). */
/** Re-read HEAD for every head-mode document; a commit advances the baseline (D13). */
private async refreshHeadBaselines(): Promise<void> {
for (const [key, mode] of this.modes) {
if (mode !== "head") continue;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
if (!doc) continue;
const head = await this.git.headFor(doc.uri);
if (head && head.text !== this.baselines.get(key)?.text) {
this.setBaseline(key, head.text, "head"); // commit advanced the baseline (D13)
}
}
}
/** Human pin ("Mark Changes as Reviewed", D14): snapshot mode only — a
* git-tracked (head-mode) baseline advances only by commit. */
pin(document: vscode.TextDocument): void {
if (!this.isDiffable(document)) return;
const key = this.uriKey(document);
if (this.modes.get(key) !== "snapshot") {
void vscode.window.showWarningMessage(
"Cowriting: this document is git-tracked — commit to advance the baseline.",
);
return;
}
this.capture(document, "pinned");
}
@@ -103,12 +160,38 @@ export class DiffViewController implements vscode.Disposable {
const baseline: Baseline = { uri: key, text: document.getText(), capturedAt: new Date().toISOString(), reason };
this.baselines.set(key, baseline);
if (this.store && this.isPersistable(document)) {
this.trySave(key, baseline);
}
this.fire(key);
}
/** Set a baseline from resolved text directly (head mode — no document buffer read). */
private setBaseline(key: string, text: string, reason: BaselineReason): void {
const baseline: Baseline = { uri: key, text, capturedAt: new Date().toISOString(), reason };
this.baselines.set(key, baseline);
// head-mode baselines are storage-free (INV-7); persist only snapshots.
if (reason !== "head" && this.store) this.trySave(key, baseline);
this.fire(key);
}
private tryLoad(key: string): Baseline | null {
try {
this.store.save(this.storageKey(key), baseline);
return this.store!.load(this.storageKey(key));
} catch {
this.warnStorageOnce();
return null;
}
}
private trySave(key: string, baseline: Baseline): void {
try {
this.store!.save(this.storageKey(key), baseline);
} catch {
this.warnStorageOnce();
}
}
private fire(key: string): void {
this.onDidChangeBaselineEmitter.fire({ uri: key });
}
@@ -122,7 +205,7 @@ export class DiffViewController implements vscode.Disposable {
private pinCommand(editor: vscode.TextEditor | undefined): void {
if (!editor || !this.isDiffable(editor.document)) {
void vscode.window.showWarningMessage("Cowriting: focus a text editor to pin its review baseline.");
void vscode.window.showWarningMessage("Cowriting: focus a text editor to mark its changes as reviewed.");
return;
}
this.pin(editor.document);
+1 -1
View File
@@ -57,7 +57,7 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
// Refresh committed decorations when the baseline advances (pin / machine-landing / open).
// Refresh committed decorations when the baseline changes (establish / pin / head-refresh).
this.diffView.onDidChangeBaseline(({ uri }) => {
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
if (ed) this.renderEditor(ed);
+20 -15
View File
@@ -10,6 +10,7 @@ import { BaselineStore } from "./baselineStore";
import { GlobalSidecarStore } from "./globalSidecarStore";
import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController";
import { GitBaselineAdapter } from "./gitBaseline";
import { TrackChangesPreviewController } from "./trackChangesPreview";
import { LiveProgressUi } from "./liveProgressUi";
import { EditorProposalController } from "./editorProposalController";
@@ -87,17 +88,21 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
);
coeditingRegistry.syncContext(vscode.window.activeTextEditor);
// --- F6: baseline data layer (Features #17 + #19) — workspace-INDEPENDENT ---
// The two-pane vscode.diff VIEW was removed in #34 (F10's rendered preview is
// the single review surface); only the baseline store survives, consumed by
// F7/F10. It tracks a baseline for ANY diffable doc (file or untitled), so it
// is constructed regardless of whether a folder is open. Baseline lives in VS
// Code's per-extension GLOBAL storage (always present), never the repo
// (INV-19). The machine-landing advance is wired below. The controller
// self-wires baseline capture on open.
// --- Baseline router (native-surfaces migration, spec §6.4, INV-7) — workspace-
// INDEPENDENT --- The two-pane vscode.diff VIEW was removed in #34 (F10's
// rendered preview is the single review surface); only the baseline data
// layer survives, consumed by F7/F10. It tracks a baseline for ANY diffable
// doc (file or untitled), so it is constructed regardless of whether a folder
// is open. A SNAPSHOT baseline lives in VS Code's per-extension GLOBAL
// storage (always present), never the repo (INV-19); a git-tracked doc's HEAD
// baseline is never persisted (INV-7). Baselines are established only for
// documents the CoeditingRegistry has entered — see the registry wiring
// inside DiffViewController's constructor.
const gitBaseline = new GitBaselineAdapter();
context.subscriptions.push(gitBaseline);
const baselineStorageDir = context.globalStorageUri?.fsPath;
const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null;
const diffViewController = new DiffViewController(baselineStore);
const diffViewController = new DiffViewController(baselineStore, gitBaseline, coeditingRegistry);
context.subscriptions.push(diffViewController);
// F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage
@@ -176,12 +181,12 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
}),
);
// --- 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.
context.subscriptions.push(
attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)),
);
// NOTE (native-surfaces migration, INV-7/INV-18 retirement): the shipped
// machine-landing baseline advance (#48) — wiring
// attributionController.onDidApplyAgentEdit → diffViewController.advance —
// was removed here. A landed Claude edit now stays a visible change-since-
// baseline until the file is committed (head mode) or reviewed via
// "Mark Changes as Reviewed" (snapshot mode).
// One SHARED sidecar watcher for both controllers; self-writes are suppressed
// centrally in the repo store (only repo `.threads/` sidecars are watched —
+131
View File
@@ -0,0 +1,131 @@
/**
* GitBaselineAdapter — resolves a document's git-HEAD baseline via the built-in
* Git extension API (spec §6.4, INV-7). Spike-verified: Repository.show('HEAD',
* path) + state.onDidChange work as assumed; the change event is coarse, so
* re-reads key off state.HEAD.commit; a nested repo may need openRepository().
*
* Hardening (Task 2 E2E, host-verified): for a repo that is NOT a workspace
* folder (the common case here — F8/#19 precedent, coediting works on any
* file), the git extension's own filesystem watcher does not reliably notice
* an out-of-band commit (e.g. a `git commit` run from a terminal, or in the
* host E2E) — `repo.state.onDidChange` can simply never fire. We additionally
* watch `.git/logs/HEAD` (the reflog VS Code itself also watches for
* in-workspace repos) with Node's own `fs.watch` and nudge `repo.status()`,
* which reliably re-syncs `state.HEAD` and lets the existing onDidChange path
* do the rest. Best-effort: any failure here just falls back to the passive
* listener.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import * as vscode from "vscode";
interface GitRepository {
rootUri: vscode.Uri;
state: { HEAD?: { commit?: string }; onDidChange: vscode.Event<void> };
show(ref: string, path: string): Promise<string>;
status(): Promise<void>;
}
interface GitAPI {
repositories: GitRepository[];
getRepository(uri: vscode.Uri): GitRepository | null;
openRepository(root: vscode.Uri): Promise<GitRepository | null>;
onDidOpenRepository: vscode.Event<GitRepository>;
}
export interface HeadBaseline { text: string; commit: string }
export class GitBaselineAdapter implements vscode.Disposable {
private api: GitAPI | null | undefined; // undefined = not resolved yet; null = unavailable
private readonly hooked = new WeakSet<GitRepository>();
private readonly lastCommit = new Map<string, string>(); // repo root → HEAD commit
private readonly emitter = new vscode.EventEmitter<void>();
readonly onDidChangeHead = this.emitter.event;
private readonly disposables: vscode.Disposable[] = [this.emitter];
private async gitApi(): Promise<GitAPI | null> {
if (this.api !== undefined) return this.api;
try {
const ext = vscode.extensions.getExtension("vscode.git");
if (!ext) return (this.api = null);
const exports = ext.isActive ? ext.exports : await ext.activate();
this.api = exports.getAPI(1) as GitAPI;
this.disposables.push(this.api.onDidOpenRepository((r) => this.hook(r)));
for (const r of this.api.repositories) this.hook(r);
} catch {
this.api = null;
}
return this.api;
}
/** HEAD blob text + commit for a tracked file; null when untracked / no repo / no HEAD. */
async headFor(uri: vscode.Uri): Promise<HeadBaseline | null> {
if (uri.scheme !== "file") return null;
const git = await this.gitApi();
if (!git) return null;
let repo = git.getRepository(uri);
if (!repo) repo = await this.discoverNested(git, uri);
if (!repo) return null;
this.hook(repo);
try {
const text = await repo.show("HEAD", uri.fsPath);
return { text, commit: repo.state.HEAD?.commit ?? "" };
} catch {
return null; // untracked, ignored, or no commit yet → snapshot mode
}
}
/** Spike finding: a nested repo may not be auto-opened — walk up for .git, then openRepository. */
private async discoverNested(git: GitAPI, uri: vscode.Uri): Promise<GitRepository | null> {
let dir = path.dirname(uri.fsPath);
for (let i = 0; i < 32; i++) {
if (fs.existsSync(path.join(dir, ".git"))) {
await git.openRepository(vscode.Uri.file(dir));
return git.getRepository(uri);
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
/** Watch a repo; fire onDidChangeHead only when HEAD's commit actually moves. */
private hook(repo: GitRepository): void {
if (this.hooked.has(repo)) return;
this.hooked.add(repo);
this.lastCommit.set(repo.rootUri.toString(), repo.state.HEAD?.commit ?? "");
this.disposables.push(
repo.state.onDidChange(() => {
const key = repo.rootUri.toString();
const commit = repo.state.HEAD?.commit ?? "";
if (this.lastCommit.get(key) !== commit) {
this.lastCommit.set(key, commit);
this.emitter.fire();
}
}),
);
this.watchReflog(repo);
}
/** Best-effort nudge: watch `.git/logs/HEAD` directly and force `repo.status()`
* on any write, so an out-of-band commit is picked up even when the git
* extension's own watcher doesn't fire (see the class doc). */
private watchReflog(repo: GitRepository): void {
try {
const reflog = path.join(repo.rootUri.fsPath, ".git", "logs", "HEAD");
if (!fs.existsSync(reflog)) return;
let pending: ReturnType<typeof setTimeout> | undefined;
const watcher = fs.watch(reflog, () => {
if (pending) clearTimeout(pending);
pending = setTimeout(() => void repo.status().catch(() => {}), 150);
});
this.disposables.push({ dispose: () => watcher.close() });
} catch {
// best-effort only — the passive repo.state.onDidChange listener still applies.
}
}
dispose(): void {
for (const d of this.disposables) d.dispose();
}
}
+7 -2
View File
@@ -380,8 +380,13 @@ export class ProposalController implements vscode.Disposable {
/**
* F12/#64 (INV-51): ACCEPT an optimistically-applied proposal — the text is
* already in the buffer, so this only advances the F6 baseline (machine-landing,
* via `attribution.signalLanded`) and clears the proposal. No re-application.
* already in the buffer, so this only marks the attribution landing (via
* `attribution.signalLanded`) and clears the proposal. No re-application.
* Native-surfaces migration (INV-7/INV-18 retirement, spec §6.4): this no
* longer advances the baseline — a landed proposal stays a visible change-
* since-baseline until commit (head mode) or "Mark Changes as Reviewed"
* (snapshot mode); nothing here subscribes to `signalLanded` for baseline
* purposes anymore (the `onDidApplyAgentEdit → advance` wire was removed).
*/
async finalizeInPlace(docPath: string, proposalId: string): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
+8 -5
View File
@@ -548,11 +548,14 @@ export interface RenderOptions {
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
render?: (src: string) => string;
/**
* #48: the baseline was just PINNED (`reason === "pinned"`). With zero changes
* since that pin, the on-render is fully clean — no authorship coloring — so a
* pin reads as "this is my clean starting point". Distinct from a baseline
* advanced by a machine-landing (accept), which keeps its authorship coloring
* (F10 INV-33). Only consulted by `renderReview`.
* #48: the baseline was just PINNED (`reason === "pinned"` — "Mark Changes as
* Reviewed" in snapshot mode). With zero changes since that pin, the
* on-render is fully clean — no authorship coloring — so a pin reads as
* "this is my clean starting point". Distinct from an ordinary accepted
* proposal, which — since the native-surfaces migration retired the
* machine-landing baseline advance (INV-18/INV-7) — stays a visible change-
* since-baseline and keeps its authorship coloring (F10 INV-33). Only
* consulted by `renderReview`.
*/
pinned?: boolean;
}
+5 -5
View File
@@ -418,15 +418,15 @@ export class TrackChangesPreviewController implements vscode.Disposable {
}
private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string {
if (!baseline) return "opened (no baseline yet)";
if (!baseline) return "not coediting (no baseline yet)";
const time = new Date(baseline.capturedAt).toLocaleTimeString();
switch (baseline.reason) {
case "machine-landing":
return `Claude landed ${time}`;
case "head":
return `HEAD ${time}`;
case "pinned":
return `pinned ${time}`;
return `reviewed ${time}`;
default:
return `opened ${time}`;
return `entered ${time}`;
}
}
+39 -3
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { BaselineStore, type Baseline } from "../src/baselineStore";
import { BaselineStore, normalizeReason, type Baseline } from "../src/baselineStore";
let dir: string;
@@ -16,9 +16,23 @@ afterEach(() => {
const KEY = "a1b2c3"; // a stand-in for the controller's sha256(uri) key
function sample(uri = "file:///ws/notes/chapter-1.md"): Baseline {
return { uri, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" };
return { uri, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "entered" };
}
describe("normalizeReason (INV-7 legacy migration)", () => {
it("migrates legacy 'opened' → 'entered'", () => {
expect(normalizeReason("opened")).toBe("entered");
});
it("migrates legacy 'machine-landing' → 'entered'", () => {
expect(normalizeReason("machine-landing")).toBe("entered");
});
it("passes current reasons through unchanged", () => {
expect(normalizeReason("entered")).toBe("entered");
expect(normalizeReason("pinned")).toBe("pinned");
expect(normalizeReason("head")).toBe("head");
});
});
describe("BaselineStore", () => {
it("returns null for a key with no baseline", () => {
expect(new BaselineStore(dir).load(KEY)).toBeNull();
@@ -36,9 +50,31 @@ describe("BaselineStore", () => {
expect(store.load(KEY)).toEqual(b);
});
it("migrates a legacy on-disk reason ('opened') to 'entered' on load", () => {
const store = new BaselineStore(dir);
fs.mkdirSync(path.dirname(store.baselinePath(KEY)), { recursive: true });
fs.writeFileSync(
store.baselinePath(KEY),
JSON.stringify({ uri: "file:///a.md", text: "legacy\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }),
"utf8",
);
expect(store.load(KEY)?.reason).toBe("entered");
});
it("migrates a legacy on-disk reason ('machine-landing') to 'entered' on load", () => {
const store = new BaselineStore(dir);
fs.mkdirSync(path.dirname(store.baselinePath(KEY)), { recursive: true });
fs.writeFileSync(
store.baselinePath(KEY),
JSON.stringify({ uri: "file:///a.md", text: "legacy\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "machine-landing" }),
"utf8",
);
expect(store.load(KEY)?.reason).toBe("entered");
});
it("overwrites in place: the newest epoch wins, no history kept", () => {
const store = new BaselineStore(dir);
store.save(KEY, { uri: "untitled:Untitled-1", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" });
store.save(KEY, { uri: "untitled:Untitled-1", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "entered" });
store.save(KEY, { uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
expect(store.load(KEY)).toEqual({ uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
});
@@ -79,22 +79,25 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
// F6 (#19) baseline data layer is workspace-INDEPENDENT: it captures a baseline
// for an untitled buffer even with no folder open (the two-pane VIEW was
// removed in #34; only the data layer remains). pinDiffBaseline stays real.
// removed in #34; only the data layer remains). markReviewed stays real.
// Native-surfaces migration (Task 2, INV-7): the baseline is established on
// coediting ENTRY, not merely on open.
test("F6 baseline data layer works with no folder open (untitled buffer)", async () => {
const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline registered");
assert.ok(all.includes("cowriting.markReviewed"), "markReviewed registered");
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
const untitled = await vscode.workspace.openTextDocument({ content: "no-folder scratch\n", language: "markdown" });
await vscode.window.showTextDocument(untitled);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await new Promise((r) => setTimeout(r, 300));
const key = untitled.uri.toString();
const baseline = api.diffViewController.getBaseline(key);
assert.ok(baseline, "baseline captured for the untitled buffer with no folder");
assert.strictEqual(baseline!.reason, "opened");
// pin resets the baseline to now — works folder-less.
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
assert.strictEqual(baseline!.reason, "entered");
// markReviewed resets the baseline to now — works folder-less.
await vscode.commands.executeCommand("cowriting.markReviewed");
await new Promise((r) => setTimeout(r, 300));
assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "pin works with no folder");
assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "markReviewed works with no folder");
});
});
+9 -5
View File
@@ -16,10 +16,12 @@ async function getApi(): Promise<CowritingApi> {
// 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 PENDING proposal blocks as cw-ins-claude. After the
// proposal is accepted the baseline advances (INV-18/F12) and the text becomes
// "unchanged" → renders plain. The class to check is cw-ins-claude (proposal block),
// NOT cw-by-claude (the old F9 authorship render). Owns its own markdown doc.
// now author-colors Claude's PENDING proposal blocks as cw-ins-claude. This
// suite checks that render BEFORE accepting (the class to check is cw-ins-claude,
// the proposal block, NOT cw-by-claude — the old F9 authorship render) and that
// the attribution data layer survives the accept; it does not exercise the F6
// baseline (see diffView.test.ts / trackChangesPreview.test.ts / baselineRouter.test.ts
// for the post-accept baseline behavior, INV-7/D21). Owns its own markdown doc.
suite("F10 review preview — pending Claude proposal renders cw-ins-claude in the on-state (host E2E, no LLM)", () => {
const DOC_REL = "docs/f10claude.md";
const TARGET = "The sentence Claude will compose over.";
@@ -65,7 +67,9 @@ suite("F10 review preview — pending Claude proposal renders cw-ins-claude in t
"pending Claude proposal block carries cw-ins-claude in the on-state render",
);
// accept via the seam — baseline advances (machine-landing, INV-18/F12)
// accept via the seam — the proposal lands; the baseline no longer auto-advances
// (INV-18/#48 retired, spec INV-7), but that isn't asserted here (no coediting
// entry / baseline established for this doc — only the attribution data layer).
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle();
+48
View File
@@ -0,0 +1,48 @@
import * as assert from "node:assert";
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as vscode from "vscode";
import { activateApi, settle, settleUntil } from "./helpers";
suite("baseline router (PUC-1, INV-7/D13/D14)", () => {
test("snapshot mode: enter captures; markReviewed re-pins clean", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "one\n" });
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
assert.strictEqual(api.diffViewController.modeOf(doc.uri.toString()), "snapshot");
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "one\n");
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "two\n"));
await vscode.commands.executeCommand("cowriting.markReviewed");
const b = api.diffViewController.getBaseline(doc.uri.toString());
assert.strictEqual(b?.text, "one\ntwo\n");
assert.strictEqual(b?.reason, "pinned");
});
test("head mode: baseline = HEAD; commit advances it", async function () {
this.timeout(30000);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-git-"));
const git = (...a: string[]) => execFileSync("git", ["-C", dir, ...a], { encoding: "utf8" });
git("init", "-q");
fs.writeFileSync(path.join(dir, "doc.md"), "committed\n");
git("add", "doc.md");
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "c1");
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(path.join(dir, "doc.md")));
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settleUntil(() => api.diffViewController.modeOf(doc.uri.toString()) === "head", 10000);
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "committed\n");
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "uncommitted\n"));
await doc.save();
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-aqm", "c2");
await settleUntil(
() => api.diffViewController.getBaseline(doc.uri.toString())?.text === "committed\nuncommitted\n",
15000,
);
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.reason, "head");
});
});
+43 -23
View File
@@ -15,6 +15,16 @@ async function openDoc(): Promise<vscode.TextDocument> {
await vscode.window.showTextDocument(doc);
return doc;
}
/** Open the doc AND enter coediting (INV-10) baselines are established only
* on entry (Task 2). Idempotent across tests: re-entering an already-coedited
* doc is a no-op, so the once-captured baseline survives (order-dependent
* suite, same as before). */
async function openAndCoedit(): Promise<vscode.TextDocument> {
const doc = await openDoc();
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return doc;
}
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
@@ -29,22 +39,29 @@ const settle = () => new Promise((r) => setTimeout(r, 300));
// later tests consume earlier state. Owns docs/diffview.md exclusively. The
// baseline works on ANY file (#19), so the last two tests use an out-of-workspace
// file and an untitled buffer.
//
// Native-surfaces migration (Task 2, spec §6.4/INV-7): a baseline is now
// established only once a document ENTERS coediting (not merely opened), and
// the fixture workspace is NOT a git repo, so every doc here resolves to
// SNAPSHOT mode. The shipped machine-landing baseline advance (#48/INV-18) is
// retired — an accepted proposal now stays a visible change-since-baseline
// until "Mark Changes as Reviewed" (D21).
suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, no LLM)", () => {
const TARGET = "A target sentence Claude will rewrite via the seam.";
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
test("opening a tracked doc captures an `opened` baseline equal to the buffer (INV-18)", async () => {
const doc = await openDoc();
await getApi();
test("entering coediting on a snapshot-mode doc captures an `entered` baseline equal to the buffer (INV-7)", async () => {
const doc = await openAndCoedit();
const api = await getApi();
assert.strictEqual(api.diffViewController.modeOf(docUri()), "snapshot", "no git repo → snapshot mode");
const baseline = api.diffViewController.getBaseline(docUri());
assert.ok(baseline, "baseline captured on first sight");
assert.strictEqual(baseline!.reason, "opened");
assert.strictEqual(baseline!.text, doc.getText(), "baseline = open-time buffer");
assert.ok(baseline, "baseline captured on coediting entry");
assert.strictEqual(baseline!.reason, "entered");
assert.strictEqual(baseline!.text, doc.getText(), "baseline = entry-time buffer");
});
test("typing leaves the baseline unchanged while the buffer diverges", async () => {
const doc = await openDoc();
const doc = await openAndCoedit();
const api = await getApi();
const before = api.diffViewController.getBaseline(docUri())!.text;
const edit = new vscode.WorkspaceEdit();
@@ -55,9 +72,10 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
assert.notStrictEqual(doc.getText(), before, "buffer diverged");
});
test("accepting a proposal advances the baseline past the landed text (PUC-2, INV-18)", async () => {
const doc = await openDoc();
test("accepting a proposal does NOT advance the baseline the landed text stays a change (INV-7/D21, #48 retired)", async () => {
const doc = await openAndCoedit();
const api = await getApi();
const before = api.diffViewController.getBaseline(docUri())!;
const start = doc.getText().indexOf(TARGET);
assert.ok(start >= 0, "fixture contains the target");
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
@@ -73,14 +91,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle();
const baseline = api.diffViewController.getBaseline(docUri())!;
assert.strictEqual(baseline.reason, "machine-landing", "baseline advanced on the landing");
assert.ok(baseline.text.includes(REPLACEMENT), "landed text is in the baseline (won't show as a change)");
assert.ok(!baseline.text.includes(TARGET), "old target gone from the baseline too");
assert.strictEqual(baseline.text, doc.getText(), "baseline == buffer right after the landing");
assert.strictEqual(baseline.reason, before.reason, "baseline reason untouched by the landing");
assert.strictEqual(baseline.text, before.text, "baseline text untouched by the landing");
assert.ok(!baseline.text.includes(REPLACEMENT), "landed text is NOT folded into the baseline");
assert.notStrictEqual(baseline.text, doc.getText(), "baseline != buffer the landing reads as a change");
});
test("an operator edit after the landing makes baseline ≠ buffer (the operator delta)", async () => {
const doc = await openDoc();
test("an operator edit after the landing keeps baseline ≠ buffer (the operator delta)", async () => {
const doc = await openAndCoedit();
const api = await getApi();
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, new vscode.Position(0, 0), "POST-LANDING OPERATOR LINE\n");
@@ -89,14 +107,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
assert.notStrictEqual(
api.diffViewController.getBaseline(docUri())!.text,
doc.getText(),
"operator changes show against the advanced baseline",
"operator changes show against the untouched baseline",
);
});
test("pin resets the baseline to now: baseline == buffer, reason pinned", async () => {
const doc = await openDoc();
test("markReviewed resets the baseline to now: baseline == buffer, reason pinned", async () => {
const doc = await openAndCoedit();
const api = await getApi();
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
await vscode.commands.executeCommand("cowriting.markReviewed");
await settle();
const baseline = api.diffViewController.getBaseline(docUri())!;
assert.strictEqual(baseline.reason, "pinned");
@@ -104,14 +122,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
});
test("the baseline is persisted in GLOBAL storage, never the repo (INV-19)", async () => {
await openDoc();
await openAndCoedit();
const api = await getApi();
const p = api.diffViewController.baselineFilePath(docUri());
assert.ok(p, "storage-backed baseline path is available for a file: doc");
assert.ok(fs.existsSync(p!), `baseline file exists at ${p}`);
const onDisk = JSON.parse(fs.readFileSync(p!, "utf8"));
assert.strictEqual(onDisk.uri, docUri(), "baseline records the document URI");
assert.strictEqual(onDisk.reason, "pinned", "last epoch (pin) persisted");
assert.strictEqual(onDisk.reason, "pinned", "last epoch (markReviewed) persisted");
assert.strictEqual(onDisk.text, api.diffViewController.getBaseline(docUri())!.text, "on-disk == in-memory");
// INV-19: baseline lives under the extension's storage dir, not the repo.
assert.ok(!p!.includes(`${path.sep}.threads${path.sep}`), "baseline is NOT in the sidecar tree");
@@ -127,11 +145,12 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
const outsideUri = vscode.Uri.file(outsidePath);
const doc = await vscode.workspace.openTextDocument(outsideUri);
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
// Captured on open even though it is NOT under the workspace folder.
// Captured on coediting entry even though it is NOT under the workspace folder.
const baseline = api.diffViewController.getBaseline(outsideUri.toString());
assert.ok(baseline, "baseline captured for an out-of-folder file");
assert.strictEqual(baseline!.reason, "opened");
assert.strictEqual(baseline!.reason, "entered");
const fp = api.diffViewController.baselineFilePath(outsideUri.toString());
assert.ok(fp && fs.existsSync(fp), "outside-file baseline persisted in global storage");
assert.ok(!fp!.startsWith(WS + path.sep), "not under the workspace folder");
@@ -143,6 +162,7 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
const api = await getApi();
const untitled = await vscode.workspace.openTextDocument({ content: "scratch line\n", language: "markdown" });
await vscode.window.showTextDocument(untitled);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const key = untitled.uri.toString();
assert.strictEqual(untitled.uri.scheme, "untitled", "it really is an untitled buffer");
+14 -6
View File
@@ -15,7 +15,9 @@ async function getApi(): Promise<CowritingApi> {
return api;
}
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key.
* Also enters coediting (INV-10/Task 2): the baseline is established only on
* entry, not merely on open. */
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 });
@@ -23,6 +25,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return { doc, key: uri.toString() };
}
@@ -114,7 +117,7 @@ suite("F10 interactive review (host E2E — preview is the single review surface
assert.ok(doc.getText().includes("A FIRST claude REPLACEMENT sentence."), "F12 optimistic-apply: replacement in buffer");
});
test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => {
test("accept → the proposal lands, clears from the preview, and stays a visible change-since-baseline (PUC-4, INV-7/D21)", async () => {
const { doc, key } = await reopen(DOC_REL);
const api = await getApi();
const id = api.proposalController.listProposals(doc).find((v) => v.replaced === T1)!.id;
@@ -126,10 +129,12 @@ suite("F10 interactive review (host E2E — preview is the single review surface
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.
// Native-surfaces migration (INV-7/D21): the shipped machine-landing baseline
// advance (#48/INV-18) is retired — the landed text stays a visible change
// until "Mark Changes as Reviewed".
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)");
assert.ok(marked, "the just-landed Claude text still renders as a change (baseline does not auto-advance)");
});
test("reject → the proposal vanishes and the document is reverted (PUC-5)", async () => {
@@ -228,8 +233,8 @@ suite("F10 interactive review (host E2E — preview is the single review surface
(k) => k.command === "cowriting.toggleDiffView",
);
assert.ok(!dKb, "the toggleDiffView keybinding is gone from package.json (#34)");
// …but the F6 baseline data layer survives: pinDiffBaseline stays a real command.
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline (baseline data layer) is kept");
// …but the F6 baseline data layer survives: markReviewed stays a real command.
assert.ok(all.includes("cowriting.markReviewed"), "markReviewed (baseline data layer) is kept");
void key;
});
@@ -297,6 +302,9 @@ async function reopen(rel: string): Promise<{ doc: vscode.TextDocument; key: str
const uri = vscode.Uri.file(path.join(WS, rel));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
// Idempotent (INV-10): the doc entered coediting in freshDoc(); re-entering
// is a no-op that leaves the once-captured baseline untouched.
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return { doc, key: uri.toString() };
}
+8 -4
View File
@@ -14,7 +14,9 @@ async function getApi(): Promise<CowritingApi> {
return api;
}
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key.
* Also enters coediting (INV-10/Task 2): the baseline is established only on
* entry, not merely on open. */
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 });
@@ -22,6 +24,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return { doc, key: uri.toString() };
}
@@ -57,12 +60,13 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
});
// SLICE-1 reachability: the orphaned pin command gets a real palette `when`.
test("pinDiffBaseline is reachable from the command palette (when: editorLangId == markdown)", async () => {
// Renamed cowriting.pinDiffBaseline → cowriting.markReviewed (Task 2, D14).
test("markReviewed is reachable from the command palette (when: editorLangId == markdown)", async () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.pinDiffBaseline",
(m) => m.command === "cowriting.markReviewed",
);
assert.ok(entry, "pinDiffBaseline has a commandPalette entry");
assert.ok(entry, "markReviewed has a commandPalette entry");
assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
});
+3
View File
@@ -16,6 +16,8 @@ async function getApi(): Promise<CowritingApi> {
return api;
}
/** Also enters coediting (INV-10/Task 2): the baseline is established only on
* entry, not merely on open. */
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 });
@@ -23,6 +25,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return { doc, key: uri.toString() };
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Shared host-E2E helpers (native-surfaces migration, Task 2). NEW suites use
* these; existing suites keep their own local copies (out of scope to
* refactor them onto this shared file see the Task 2 brief).
*/
import * as assert from "node:assert";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
/** Activate the extension and return its exported API (asserts it is real). */
export async function activateApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.diffViewController, "extension exports diffViewController");
return api;
}
/** A short fixed settle, for state that updates synchronously-ish after a command. */
export function settle(): Promise<void> {
return new Promise((r) => setTimeout(r, 150));
}
/** Poll `predicate` every 100ms until it is true, or fail after `timeoutMs`. */
export async function settleUntil(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
assert.fail(`settleUntil: predicate did not become true within ${timeoutMs}ms`);
}
await new Promise((r) => setTimeout(r, 100));
}
}
+3
View File
@@ -14,6 +14,8 @@ async function getApi(): Promise<CowritingApi> {
return api;
}
/** Also enters coediting (INV-10/Task 2): the baseline is established only on
* entry, not merely on open. */
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 });
@@ -21,6 +23,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return { doc, key: uri.toString() };
}
+13 -8
View File
@@ -7,10 +7,14 @@ const WS = process.env.E2E_WORKSPACE!;
const DOC_REL = "docs/preview.md";
const docUri = () => vscode.Uri.file(path.join(WS, DOC_REL)).toString();
/** Open the doc AND enter coediting (INV-10/Task 2) the baseline is
* established only on entry; re-entering an already-coedited doc is a no-op
* so the once-captured baseline survives across this order-dependent suite. */
async function openDoc(rel = DOC_REL): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, rel));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc;
}
async function getApi(): Promise<CowritingApi> {
@@ -58,7 +62,7 @@ suite("F7 track-changes preview (host E2E — markdown only, programmatic seam,
);
});
test("accepting a proposal advances the baseline; the landed block goes unchanged (PUC-3, INV-18)", async () => {
test("accepting a proposal does NOT advance the baseline; the landed block stays marked (PUC-3, INV-7/D21, #48 retired)", async () => {
const doc = await openDoc();
const api = await getApi();
const start = doc.getText().indexOf(TARGET);
@@ -75,23 +79,24 @@ suite("F7 track-changes preview (host E2E — markdown only, programmatic seam,
assert.ok(id, "propose returns an id");
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle();
// The baseline advanced to include REPLACEMENT, so the buffer == baseline for
// that block → it is NOT marked. Confirm the replacement is not flagged as a change.
// Native-surfaces migration (INV-7/D21): the shipped machine-landing baseline
// advance (#48/INV-18) is retired — the landed block stays a visible change
// until "Mark Changes as Reviewed".
const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
const replacementMarked = model.some(
(o) => o.kind !== "unchanged" && o.block.raw.includes("CLAUDE REWROTE"),
);
assert.ok(!replacementMarked, "the just-landed text renders unmarked (baseline advanced)");
assert.ok(replacementMarked, "the just-landed text still renders marked (baseline does not auto-advance)");
});
test("pin resets the baseline to now → preview shows no marks (PUC-4)", async () => {
test("markReviewed resets the baseline to now → preview shows no marks (PUC-4)", async () => {
const doc = await openDoc();
const api = await getApi();
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
await vscode.commands.executeCommand("cowriting.markReviewed");
await settle();
assert.ok(
kinds(api).every((k) => k === "unchanged"),
"after pin, every block is unchanged (baseline == buffer)",
"after markReviewed, every block is unchanged (baseline == buffer)",
);
void doc;
});
@@ -112,7 +117,7 @@ suite("F7 track-changes preview (host E2E — markdown only, programmatic seam,
// Show the preview and pin the baseline so the fixture's `a --> b` flowchart
// is the "before"; then add an edge `a --> c` and confirm the intra-diagram diff.
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
await vscode.commands.executeCommand("cowriting.markReviewed");
await settle();
const anchor = doc.getText().indexOf("a --> b");
+4
View File
@@ -15,6 +15,9 @@ async function getApi(): Promise<CowritingApi> {
return api;
}
/** Also enters coediting (INV-10/Task 2): the baseline is established only on
* entry, not merely on open needed for the preview's added/changed marks to
* reflect a real diff rather than a vacuous baseline==current fallback. */
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 });
@@ -22,6 +25,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return { doc, key: uri.toString() };
}
+2 -2
View File
@@ -440,9 +440,9 @@ describe("renderReview", () => {
expect(html).toContain('data-proposal-id="p1"'); // proposal still shows (it is an action)
expect(html).toContain("Person");
});
test("renderReview: zero diff (e.g. machine-landing accept) renders unchanged blocks plain (Task 2 behavior)", () => {
test("renderReview: zero diff (baseline == current) renders unchanged blocks plain (Task 2 behavior)", () => {
// Task 2: unchanged blocks always render plain — color means "changed since baseline".
// After accepting a Claude edit the text is the new baseline; no diff → no coloring.
// When baseline == current (e.g. right after a fresh snapshot) there's no diff → no coloring.
const doc = "Human wrote this.\n\nClaude wrote that.";
const spans: AuthorSpan[] = [{ start: 19, end: doc.length, author: "claude" }];
const html = renderReview(doc, doc, spans, []); // no pinned flag