feat: gate every surface on CoeditingRegistry (INV-10) — commenting ranges re-assigned on gate change (spec §6.4 v0.2.1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 07:41:13 -07:00
parent cf65528711
commit a323b827a8
18 changed files with 171 additions and 18 deletions
+6 -6
View File
@@ -173,11 +173,11 @@
}, },
{ {
"command": "cowriting.acceptAllProposals", "command": "cowriting.acceptAllProposals",
"when": "editorLangId == markdown" "when": "editorLangId == markdown && cowriting.isCoediting"
}, },
{ {
"command": "cowriting.rejectAllProposals", "command": "cowriting.rejectAllProposals",
"when": "editorLangId == markdown" "when": "editorLangId == markdown && cowriting.isCoediting"
}, },
{ {
"command": "cowriting.proposalAcceptMenu", "command": "cowriting.proposalAcceptMenu",
@@ -208,7 +208,7 @@
"editor/title/context": [ "editor/title/context": [
{ {
"command": "cowriting.edit", "command": "cowriting.edit",
"when": "resourceLangId == markdown", "when": "resourceLangId == markdown && cowriting.isCoediting",
"group": "1_cowriting@1" "group": "1_cowriting@1"
}, },
{ {
@@ -227,12 +227,12 @@
"editor/context": [ "editor/context": [
{ {
"command": "cowriting.edit", "command": "cowriting.edit",
"when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)", "when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting",
"group": "1_cowriting@1" "group": "1_cowriting@1"
}, },
{ {
"command": "cowriting.createThread", "command": "cowriting.createThread",
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)", "when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting",
"group": "1_cowriting@2" "group": "1_cowriting@2"
}, },
{ {
@@ -277,7 +277,7 @@
"command": "cowriting.edit", "command": "cowriting.edit",
"key": "ctrl+alt+e", "key": "ctrl+alt+e",
"mac": "cmd+alt+e", "mac": "cmd+alt+e",
"when": "editorTextFocus && editorLangId == markdown" "when": "editorTextFocus && editorLangId == markdown && cowriting.isCoediting"
} }
] ]
}, },
+4
View File
@@ -21,6 +21,7 @@ import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
import type { VersionGuard } from "./versionGuard"; import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath"; import { isAuthorable } from "./workspacePath";
import type { AuthorSpan } from "./trackChangesModel"; import type { AuthorSpan } from "./trackChangesModel";
import type { CoeditingRegistry } from "./coeditingRegistry";
/** Test-facing snapshot of live attribution state for a document. */ /** Test-facing snapshot of live attribution state for a document. */
export interface RenderedSpan { export interface RenderedSpan {
@@ -69,6 +70,7 @@ export class AttributionController implements vscode.Disposable {
private readonly store: SidecarRouter, private readonly store: SidecarRouter,
private readonly rootDir: string | undefined, private readonly rootDir: string | undefined,
private readonly guard: VersionGuard, private readonly guard: VersionGuard,
private readonly registry: CoeditingRegistry,
) { ) {
this.disposables.push(this.statusItem, this.output, this.applyEmitter); this.disposables.push(this.statusItem, this.output, this.applyEmitter);
this.disposables.push( this.disposables.push(
@@ -104,6 +106,7 @@ export class AttributionController implements vscode.Disposable {
/** Load the sidecar and re-resolve every attribution (live span | orphan). */ /** Load the sidecar and re-resolve every attribution (live span | orphan). */
loadAll(document: vscode.TextDocument): void { loadAll(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return; if (!this.isTracked(document)) return;
if (!this.registry.isCoediting(document.uri)) return;
const docPath = this.keyOf(document); const docPath = this.keyOf(document);
const s = this.state(docPath); const s = this.state(docPath);
const artifact = this.store.load(docPath); const artifact = this.store.load(docPath);
@@ -145,6 +148,7 @@ export class AttributionController implements vscode.Disposable {
private onDidChange(e: vscode.TextDocumentChangeEvent): void { private onDidChange(e: vscode.TextDocumentChangeEvent): void {
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return; if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
if (!this.registry.isCoediting(e.document.uri)) return;
const docPath = this.keyOf(e.document); const docPath = this.keyOf(e.document);
if (!e.document.isDirty && this.matchesDisk(e.document)) { if (!e.document.isDirty && this.matchesDisk(e.document)) {
// Disk sync (revert / external reload): buffer now equals the file on // Disk sync (revert / external reload): buffer now equals the file on
+15
View File
@@ -17,6 +17,7 @@ import type { DiffViewController } from "./diffViewController";
import type { AttributionController } from "./attributionController"; import type { AttributionController } from "./attributionController";
import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel"; import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel";
import { isAuthorable } from "./workspacePath"; import { isAuthorable } from "./workspacePath";
import type { CoeditingRegistry } from "./coeditingRegistry";
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider { export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
private readonly disposables: vscode.Disposable[] = []; private readonly disposables: vscode.Disposable[] = [];
@@ -51,6 +52,7 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
private readonly proposals: ProposalController, private readonly proposals: ProposalController,
private readonly diffView: DiffViewController, private readonly diffView: DiffViewController,
private readonly attribution: AttributionController, private readonly attribution: AttributionController,
private readonly registry: CoeditingRegistry,
) { ) {
this.disposables.push( this.disposables.push(
this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter, this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter,
@@ -67,6 +69,13 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
const ed = vscode.window.activeTextEditor; const ed = vscode.window.activeTextEditor;
if (ed && e.document === ed.document) this.scheduleRender(ed); if (ed && e.document === ed.document) this.scheduleRender(ed);
}), }),
// INV-10: re-render (and refresh CodeLens) on every gate change — exit clears
// decorations/CodeLens via renderEditor's own gate check, re-entry redraws.
this.registry.onDidChange(({ uri }) => {
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
if (ed) this.renderEditor(ed);
this.lensEmitter.fire();
}),
// the four QuickPick-backed menu commands // the four QuickPick-backed menu commands
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)), vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)), vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
@@ -106,6 +115,9 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
private async onProposalsChanged(uri: string): Promise<void> { private async onProposalsChanged(uri: string): Promise<void> {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (!doc || doc.languageId !== "markdown" || !isAuthorable(doc.uri.scheme)) return; if (!doc || doc.languageId !== "markdown" || !isAuthorable(doc.uri.scheme)) return;
// INV-10: a doc that exited coediting between the schedule and this tick gets
// no optimistic-apply/decorations/CodeLens.
if (!this.registry.isCoediting(doc.uri)) return;
const key = this.proposals.keyFor(doc); const key = this.proposals.keyFor(doc);
for (const v of this.proposals.listProposals(doc)) { for (const v of this.proposals.listProposals(doc)) {
if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) { if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) {
@@ -125,6 +137,8 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []); for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []);
}; };
if (doc.languageId !== "markdown") return clearAll(); if (doc.languageId !== "markdown") return clearAll();
// INV-10: a non-entered doc gets no decorations at all.
if (!this.registry.isCoediting(doc.uri)) return clearAll();
const key = this.proposals.keyFor(doc); const key = this.proposals.keyFor(doc);
const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] }; const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] };
const del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]> = { human: [], claude: [], none: [] }; const del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]> = { human: [], claude: [], none: [] };
@@ -205,6 +219,7 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */ /** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */
provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
if (document.languageId !== "markdown") return []; if (document.languageId !== "markdown") return [];
if (!this.registry.isCoediting(document.uri)) return [];
const key = this.proposals.keyFor(document); const key = this.proposals.keyFor(document);
const lenses: vscode.CodeLens[] = []; const lenses: vscode.CodeLens[] = [];
for (const v of this.proposals.listProposals(document)) { for (const v of this.proposals.listProposals(document)) {
+27 -5
View File
@@ -19,6 +19,8 @@ import { CoeditingRegistry } from "./coeditingRegistry";
import { ScmSurfaceController } from "./scmSurface"; import { ScmSurfaceController } from "./scmSurface";
const CHANNEL_NAME = "Cowriting (Cline SDK)"; const CHANNEL_NAME = "Cowriting (Cline SDK)";
/** The exact warning copy for every gated edit gesture (INV-10). */
const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first.";
export interface CowritingApi { export interface CowritingApi {
threadController: ThreadController; threadController: ThreadController;
@@ -144,16 +146,22 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
// F5 (INV-16): one shared guard — newer-major sidecars are read-only, one // F5 (INV-16): one shared guard — newer-major sidecars are read-only, one
// warning per doc across the three co-owning controllers. // warning per doc across the three co-owning controllers.
const versionGuard = new VersionGuard(sidecarRouter); const versionGuard = new VersionGuard(sidecarRouter);
const threadController = new ThreadController(sidecarRouter, root, versionGuard); const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry);
context.subscriptions.push(threadController); context.subscriptions.push(threadController);
// --- F3: live attribution (Feature #6) --- // --- F3: live attribution (Feature #6) ---
const attributionController = new AttributionController(sidecarRouter, root, versionGuard); const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry);
context.subscriptions.push(attributionController); context.subscriptions.push(attributionController);
// --- F4: propose/accept (Feature #12) — constructed before the preview so F10 // --- F4: propose/accept (Feature #12) — constructed before the preview so F10
// can route ✓/✗ through it --- // can route ✓/✗ through it ---
const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard); const proposalController = new ProposalController(
sidecarRouter,
attributionController,
root,
versionGuard,
coeditingRegistry,
);
context.subscriptions.push(proposalController); context.subscriptions.push(proposalController);
// --- F7/F10: the review preview is the single interactive review surface --- // --- F7/F10: the review preview is the single interactive review surface ---
@@ -166,12 +174,18 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
attributionController, attributionController,
proposalController, proposalController,
liveProgressUi, liveProgressUi,
coeditingRegistry,
); );
context.subscriptions.push(trackChangesPreviewController); context.subscriptions.push(trackChangesPreviewController);
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer, // --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). --- // decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
const editorProposalController = new EditorProposalController(proposalController, diffViewController, attributionController); const editorProposalController = new EditorProposalController(
proposalController,
diffViewController,
attributionController,
coeditingRegistry,
);
context.subscriptions.push(editorProposalController); context.subscriptions.push(editorProposalController);
// #46 (INV-42): accept every pending proposal on the active doc in one gesture // #46 (INV-42): accept every pending proposal on the active doc in one gesture
@@ -289,6 +303,11 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
} }
if (!editor) return; // unreachable once reason is null, but narrows the type if (!editor) return; // unreachable once reason is null, but narrows the type
const document = editor.document; const document = editor.document;
// INV-10: a non-entered doc gets the gate warning, not a turn.
if (!coeditingRegistry.isCoediting(document.uri)) {
void vscode.window.showWarningMessage(NOT_COEDITING_WARNING);
return;
}
const selection = editor.selection; // non-empty (selectionRejection guaranteed it) const selection = editor.selection; // non-empty (selectionRejection guaranteed it)
// Capture the selection text + anchor BEFORE prompting — the inline prompt // Capture the selection text + anchor BEFORE prompting — the inline prompt
// moves the cursor to the document top (where its box anchors), which would // moves the cursor to the document top (where its box anchors), which would
@@ -394,8 +413,11 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
); );
// Render threads + attributions for already-open editors, and on future opens. // Render threads + attributions for already-open editors, and on future opens.
// INV-10: gated on the registry too — belt-and-suspenders with the per-controller
// gates (renderAll/loadAll already early-return), but keeps the intent explicit
// at the call site and avoids the no-op calls for a non-entered doc.
const renderIfOpen = (doc: vscode.TextDocument) => { const renderIfOpen = (doc: vscode.TextDocument) => {
if (isAuthorable(doc.uri.scheme)) { if (isAuthorable(doc.uri.scheme) && coeditingRegistry.isCoediting(doc.uri)) {
threadController.renderAll(doc); threadController.renderAll(doc);
attributionController.loadAll(doc); attributionController.loadAll(doc);
proposalController.renderAll(doc); proposalController.renderAll(doc);
+10 -1
View File
@@ -19,6 +19,7 @@ import type { AttributionController } from "./attributionController";
import type { VersionGuard } from "./versionGuard"; import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath"; import { isAuthorable } from "./workspacePath";
import { wordEditHunks, type ProposalView } from "./trackChangesModel"; import { wordEditHunks, type ProposalView } from "./trackChangesModel";
import type { CoeditingRegistry } from "./coeditingRegistry";
/** Test-facing snapshot of what is currently rendered for a document. */ /** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedProposal { export interface RenderedProposal {
@@ -56,6 +57,7 @@ export class ProposalController implements vscode.Disposable {
private readonly attribution: AttributionController, private readonly attribution: AttributionController,
private readonly rootDir: string | undefined, private readonly rootDir: string | undefined,
private readonly guard: VersionGuard, private readonly guard: VersionGuard,
private readonly registry: CoeditingRegistry,
) { ) {
this.disposables.push(this.statusItem); this.disposables.push(this.statusItem);
this.disposables.push(this.onDidChangeProposalsEmitter); this.disposables.push(this.onDidChangeProposalsEmitter);
@@ -459,7 +461,13 @@ export class ProposalController implements vscode.Disposable {
// ---- PUC-4: load / external change / resolve-or-flag -------------------------------- // ---- PUC-4: load / external change / resolve-or-flag --------------------------------
/** Load + (re)render every pending proposal at its resolved anchor (or flagged). */ /** Load + (re)render every pending proposal at its resolved anchor (or flagged).
* INV-10: the RENDER portion (live/unresolved recompute, the status bar, and
* the onDidChangeProposals fire that drives F12's optimistic-apply/CodeLens/
* decorations) is gated on coediting `propose()` itself stays ungated (it
* always persists), and so does the state LOAD below: `acceptById`/`rejectById`
* key off `this.docs` (populated here) regardless of coediting, since deciding
* an already-persisted proposal is not a "rendering" surface. */
renderAll(document: vscode.TextDocument): void { renderAll(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return; if (!this.isTracked(document)) return;
const docPath = this.keyOf(document); const docPath = this.keyOf(document);
@@ -467,6 +475,7 @@ export class ProposalController implements vscode.Disposable {
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
state.live.clear(); state.live.clear();
state.unresolved.clear(); state.unresolved.clear();
if (!this.registry.isCoediting(document.uri)) return;
const text = document.getText(); const text = document.getText();
for (const proposal of state.artifact.proposals) { for (const proposal of state.artifact.proposals) {
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
+40 -6
View File
@@ -15,6 +15,7 @@ import { gitUserEmail } from "./identity";
import { addThread, appendMessage, setStatus } from "./threadModel"; import { addThread, appendMessage, setStatus } from "./threadModel";
import type { VersionGuard } from "./versionGuard"; import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath"; import { isAuthorable } from "./workspacePath";
import type { CoeditingRegistry } from "./coeditingRegistry";
/** Test-facing snapshot of what is currently rendered for a document. */ /** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedThread { export interface RenderedThread {
@@ -41,18 +42,24 @@ export class ThreadController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = []; private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocState>(); // keyed by docPath private readonly docs = new Map<string, DocState>(); // keyed by docPath
/** Kept as ONE object; re-assigned on every registry change so VS Code re-queries
* (spec §6.4 v0.2.1 reassignment is the API's only "ranges changed" signal). */
private readonly rangeProvider: vscode.CommentingRangeProvider = {
provideCommentingRanges: (document) => {
if (!isAuthorable(document.uri.scheme)) return [];
if (!this.registry.isCoediting(document.uri)) return [];
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
},
};
constructor( constructor(
private readonly store: SidecarRouter, private readonly store: SidecarRouter,
private readonly rootDir: string | undefined, private readonly rootDir: string | undefined,
private readonly guard: VersionGuard, private readonly guard: VersionGuard,
private readonly registry: CoeditingRegistry,
) { ) {
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
this.controller.commentingRangeProvider = { this.controller.commentingRangeProvider = this.rangeProvider;
provideCommentingRanges: (document) => {
if (!isAuthorable(document.uri.scheme)) return [];
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
},
};
this.disposables.push(this.controller); this.disposables.push(this.controller);
this.disposables.push( this.disposables.push(
@@ -66,6 +73,20 @@ export class ThreadController implements vscode.Disposable {
), ),
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
// INV-10 (PUC-7): force VS Code to re-query commenting ranges on every gate
// change, and hide/restore rendered threads — exit hides only (dispose the
// rendered vsThreads), re-enter restores (renderAll reloads from the
// sidecar, which is the durable source of truth regardless of gate state).
this.registry.onDidChange(({ uri, coediting }) => {
this.controller.commentingRangeProvider = this.rangeProvider;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (!doc) return;
if (coediting) {
this.renderAll(doc);
} else {
this.disposeRendered(this.keyOf(doc));
}
}),
); );
} }
@@ -111,6 +132,7 @@ export class ThreadController implements vscode.Disposable {
async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> { async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
const editor = vscode.window.activeTextEditor; const editor = vscode.window.activeTextEditor;
if (!editor || editor.selection.isEmpty || !isAuthorable(editor.document.uri.scheme)) return undefined; if (!editor || editor.selection.isEmpty || !isAuthorable(editor.document.uri.scheme)) return undefined;
if (!this.registry.isCoediting(editor.document.uri)) return undefined;
if (this.guard.isReadOnly(this.keyOf(editor.document))) return undefined; if (this.guard.isReadOnly(this.keyOf(editor.document))) return undefined;
const document = editor.document; const document = editor.document;
const state = this.ensureState(document); const state = this.ensureState(document);
@@ -152,6 +174,7 @@ export class ThreadController implements vscode.Disposable {
/** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */ /** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */
renderAll(document: vscode.TextDocument): void { renderAll(document: vscode.TextDocument): void {
if (!this.registry.isCoediting(document.uri)) return;
const docPath = this.keyOf(document); const docPath = this.keyOf(document);
const state = this.ensureState(document); const state = this.ensureState(document);
// fresh artifact from disk (reload / external change) // fresh artifact from disk (reload / external change)
@@ -269,6 +292,17 @@ export class ThreadController implements vscode.Disposable {
return undefined; return undefined;
} }
/** PUC-7 (INV-10): exit hides only dispose the rendered vsThreads for a doc
* without touching its sidecar-backed artifact (re-enter restores via renderAll). */
private disposeRendered(docPath: string): void {
const state = this.docs.get(docPath);
if (!state) return;
for (const vsThread of state.vsThreads.values()) vsThread.dispose();
state.vsThreads.clear();
state.live.clear();
state.orphaned.clear();
}
// ---- test-facing surface -------------------------------------------------------- // ---- test-facing surface --------------------------------------------------------
getRendered(docPath: string): RenderedThread[] { getRendered(docPath: string): RenderedThread[] {
+12
View File
@@ -19,6 +19,10 @@ import { isAuthorable } from "./workspacePath";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi"; import type { LiveProgressUi } from "./liveProgressUi";
import { promptEditInstruction } from "./editInstructionInput"; import { promptEditInstruction } from "./editInstructionInput";
import type { CoeditingRegistry } from "./coeditingRegistry";
/** The exact warning copy for every gated edit gesture (INV-10). */
const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first.";
/** /**
* F11: a host edit turn (selection/document text + instruction rewrite). * F11: a host edit turn (selection/document text + instruction rewrite).
@@ -83,6 +87,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
private readonly attribution: AttributionController, private readonly attribution: AttributionController,
private readonly proposals: ProposalController, private readonly proposals: ProposalController,
private readonly liveProgressUi: LiveProgressUi, private readonly liveProgressUi: LiveProgressUi,
private readonly registry: CoeditingRegistry,
) { ) {
this.disposables.push( this.disposables.push(
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri; // F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
@@ -247,6 +252,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`. * the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
*/ */
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> { private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
// INV-10: the choke point for BOTH the editDocument command and the webview's
// askClaude toolbar intent (either scope) — a non-entered doc gets the warning,
// not a turn.
if (!this.registry.isCoediting(document.uri)) {
void vscode.window.showWarningMessage(NOT_COEDITING_WARNING);
return;
}
// Both scopes use the same multi-line split-below webview box; only the header // Both scopes use the same multi-line split-below webview box; only the header
// (and the downstream proposal logic) differs. For a selection the document // (and the downstream proposal logic) differs. For a selection the document
// above keeps the selection highlighted while the box is open. // above keeps the selection highlighted while the box is open.
+3
View File
@@ -18,6 +18,9 @@ async function openDoc(): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, DOC_REL)); const uri = vscode.Uri.file(path.join(WS, DOC_REL));
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: attribution tracking is gated on coediting (Task 4) — idempotent
// across this order-dependent suite's repeated openDoc() calls.
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc; return doc;
} }
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
+3
View File
@@ -34,6 +34,9 @@ suite("F10 review preview — pending Claude proposal renders cw-ins-claude in t
const uri = vscode.Uri.file(abs); const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: entering coediting is required for attribution tracking + F12
// optimistic-apply to fire — this suite never entered it (pre-migration).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
const api = await getApi(); const api = await getApi();
const key = uri.toString(); const key = uri.toString();
+23
View File
@@ -32,4 +32,27 @@ suite("coediting registry (PUC-7)", () => {
assert.strictEqual(api.coeditingRegistry.isCoediting(doc.uri), false); assert.strictEqual(api.coeditingRegistry.isCoediting(doc.uri), false);
assert.strictEqual(api.scmSurfaceController.changeCount(doc.uri.toString()), 0); assert.strictEqual(api.scmSurfaceController.changeCount(doc.uri.toString()), 0);
}); });
// Task 4 (INV-10, PUC-7): the no-hijack scenario — a non-entered doc gets no
// thread-creation surface at all; entering unlocks it; exiting hides the
// rendered thread (not the sidecar-backed data); re-entering restores it.
test("non-entered doc gets no surfaces; exit detaches; re-enter restores threads", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# a\n\npara\n" });
const ed = await vscode.window.showTextDocument(doc);
// not entered → no thread creation
ed.selection = new vscode.Selection(2, 0, 2, 4);
const before = await api.threadController.createThreadOnSelection("hi");
assert.strictEqual(before, undefined);
// entered → works
await vscode.commands.executeCommand("cowriting.coeditDocument");
const id = await api.threadController.createThreadOnSelection("hi");
assert.ok(id);
// exit → rendered thread set empty; re-enter → restored from sidecar
await vscode.commands.executeCommand("cowriting.stopCoediting");
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 0);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 1);
});
}); });
+2
View File
@@ -17,6 +17,8 @@ async function open(docRel: string): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, docRel)); const uri = vscode.Uri.file(path.join(WS, docRel));
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: thread creation/rendering is gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc; return doc;
} }
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
+3
View File
@@ -21,6 +21,9 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
const uri = vscode.Uri.file(abs); const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: proposal rendering (F12 optimistic-apply, driven by
// onDidChangeProposals) is gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
return { doc, key: uri.toString() }; return { doc, key: uri.toString() };
} }
+6
View File
@@ -20,6 +20,12 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
fs.writeFileSync(abs, body, "utf8"); fs.writeFileSync(abs, body, "utf8");
const uri = vscode.Uri.file(abs); const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
// INV-10: cowriting.editDocument now warns instead of editing a non-entered
// doc (Task 4) — enter it here (briefly making it active) so the tests below
// exercise the routing/targeting behavior, not the gate. The caller
// re-establishes whichever doc it wants active afterward.
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
return { doc, key: uri.toString() }; return { doc, key: uri.toString() };
} }
+3
View File
@@ -20,6 +20,9 @@ async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument>
fs.writeFileSync(abs, body, "utf8"); fs.writeFileSync(abs, body, "utf8");
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs)); const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: attribution tracking (accept's per-word attribution, INV-40) is
// gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
return doc; return doc;
} }
+3
View File
@@ -20,6 +20,9 @@ async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument>
fs.writeFileSync(abs, body, "utf8"); fs.writeFileSync(abs, body, "utf8");
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs)); const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: proposal rendering (F12 optimistic-apply, driven by
// onDidChangeProposals) is gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
return doc; return doc;
} }
+4
View File
@@ -37,6 +37,8 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)",
const uri = vscode.Uri.file(outsidePath); const uri = vscode.Uri.file(outsidePath);
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: attribution tracking + thread creation are gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
const api = await getApi(); const api = await getApi();
const key = uri.toString(); const key = uri.toString();
@@ -80,6 +82,8 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)",
const TARGET = "The untitled draft sentence."; const TARGET = "The untitled draft sentence.";
const untitled = await vscode.workspace.openTextDocument({ content: `${TARGET}\n`, language: "markdown" }); const untitled = await vscode.workspace.openTextDocument({ content: `${TARGET}\n`, language: "markdown" });
await vscode.window.showTextDocument(untitled); await vscode.window.showTextDocument(untitled);
// INV-10: attribution tracking is gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
const api = await getApi(); const api = await getApi();
const key = untitled.uri.toString(); const key = untitled.uri.toString();
+4
View File
@@ -18,6 +18,10 @@ async function openDoc(): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, DOC_REL)); const uri = vscode.Uri.file(path.join(WS, DOC_REL));
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: proposal rendering (and the F12 optimistic-apply it drives via
// onDidChangeProposals) is gated on coediting (Task 4) — idempotent across
// this order-dependent suite's repeated openDoc() calls.
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc; return doc;
} }
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
+3
View File
@@ -18,6 +18,9 @@ async function openSample(): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, DOC_REL)); const uri = vscode.Uri.file(path.join(WS, DOC_REL));
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: thread creation/rendering is gated on coediting (Task 4) — idempotent
// across this order-dependent suite's repeated openSample() calls.
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc; return doc;
} }