F4 SLICE-2/3: ProposalController — propose ingress, resolve-or-flag, diff threads, accept/reject via the seam (#12)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* ProposalController — the editor-facing layer for F4 (spec
|
||||
* coauthoring-propose-accept §6.2). Owns the proposal lifecycle: the propose
|
||||
* ingress (INV-10: NEVER mutates the document), persistence at propose time,
|
||||
* resolve-or-flag on load/external change (INV-11 — a proposal's anchor is
|
||||
* immutable for its life: no save-time re-fingerprint, unlike threads),
|
||||
* rendering (second Comments controller + amber pending-range decoration),
|
||||
* and the human-only accept/reject gestures (INV-12). Accept drives the seam
|
||||
* (AttributionController.applyAgentEdit, INV-9) so accepted text lands
|
||||
* Claude-attributed with zero new attribution code.
|
||||
*/
|
||||
import * as vscode from "vscode";
|
||||
import { CoauthorStore } from "./store";
|
||||
import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model";
|
||||
import { resolve, shift, type OffsetRange } from "./anchorer";
|
||||
import { addProposal, proposalBody, removeProposal } from "./proposalModel";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
|
||||
/** Test-facing snapshot of what is currently rendered for a document. */
|
||||
export interface RenderedProposal {
|
||||
id: string;
|
||||
/** true → resolves exactly, decidable; false → stale/orphaned (INV-11). */
|
||||
pending: boolean;
|
||||
turnId?: string;
|
||||
range: { start: number; end: number };
|
||||
}
|
||||
|
||||
interface DocState {
|
||||
docPath: string;
|
||||
uri: vscode.Uri;
|
||||
artifact: Artifact;
|
||||
vsThreads: Map<string, vscode.CommentThread>;
|
||||
/** proposal id -> live offset range (within-session optimization, INV-3). */
|
||||
live: Map<string, OffsetRange>;
|
||||
/** proposal ids whose anchor did not resolve at last render (stale/orphaned). */
|
||||
unresolved: Set<string>;
|
||||
}
|
||||
|
||||
const PENDING_DECO: vscode.DecorationRenderOptions = {
|
||||
backgroundColor: "rgba(245, 158, 11, 0.18)",
|
||||
overviewRulerColor: "rgba(245, 158, 11, 0.8)",
|
||||
overviewRulerLane: vscode.OverviewRulerLane.Right,
|
||||
};
|
||||
|
||||
export class ProposalController implements vscode.Disposable {
|
||||
private readonly controller: vscode.CommentController;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly docs = new Map<string, DocState>(); // keyed by docPath
|
||||
private readonly pendingType = vscode.window.createTextEditorDecorationType(PENDING_DECO);
|
||||
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 89);
|
||||
|
||||
constructor(
|
||||
private readonly store: CoauthorStore,
|
||||
private readonly attribution: AttributionController,
|
||||
private readonly rootDir: string,
|
||||
) {
|
||||
// No commentingRangeProvider: humans never open proposal threads by hand —
|
||||
// proposals are born of machine turns only (INV-12 keeps decisions human).
|
||||
this.controller = vscode.comments.createCommentController("cowriting.proposals", "Claude Proposals");
|
||||
this.disposables.push(this.controller, this.pendingType, this.statusItem);
|
||||
this.disposables.push(
|
||||
vscode.commands.registerCommand("cowriting.acceptProposal", (t: vscode.CommentThread) => this.acceptThread(t)),
|
||||
vscode.commands.registerCommand("cowriting.rejectProposal", (t: vscode.CommentThread) => this.rejectThread(t)),
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||
);
|
||||
}
|
||||
|
||||
private isTracked(document: vscode.TextDocument): boolean {
|
||||
return document.uri.scheme === "file" && document.uri.fsPath.startsWith(this.rootDir);
|
||||
}
|
||||
private docPathOf(uri: vscode.Uri): string {
|
||||
return vscode.workspace.asRelativePath(uri, false);
|
||||
}
|
||||
private ensureState(document: vscode.TextDocument): DocState {
|
||||
const docPath = this.docPathOf(document.uri);
|
||||
let state = this.docs.get(docPath);
|
||||
if (!state) {
|
||||
state = {
|
||||
docPath,
|
||||
uri: document.uri,
|
||||
artifact: this.store.load(docPath) ?? emptyArtifact(docPath),
|
||||
vsThreads: new Map(),
|
||||
live: new Map(),
|
||||
unresolved: new Set(),
|
||||
};
|
||||
this.docs.set(docPath, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// ---- PUC-1: the propose ingress (INV-10) -----------------------------------------
|
||||
|
||||
/**
|
||||
* Record + render a pending proposal. The fingerprint is built by the CALLER
|
||||
* at gesture time (editSelection captures it BEFORE the turn, so mid-turn
|
||||
* edits can't skew the anchor). Never touches the document.
|
||||
*/
|
||||
async propose(
|
||||
document: vscode.TextDocument,
|
||||
fp: Fingerprint,
|
||||
replacement: string,
|
||||
author: Provenance,
|
||||
opts?: { turnId?: string; instruction?: string },
|
||||
): Promise<string | undefined> {
|
||||
if (!this.isTracked(document)) return undefined;
|
||||
const docPath = this.docPathOf(document.uri);
|
||||
let proposalId: string | undefined;
|
||||
this.store.update(docPath, (a) => {
|
||||
proposalId = addProposal(a, fp, replacement, author, opts).proposalId;
|
||||
});
|
||||
this.renderAll(document);
|
||||
return proposalId;
|
||||
}
|
||||
|
||||
// ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ----------------------------------
|
||||
|
||||
/** Accept by proposal id (test-facing twin of the thread-menu gesture). */
|
||||
async acceptById(docPath: string, proposalId: string): Promise<boolean> {
|
||||
const hit = this.byId(docPath, proposalId);
|
||||
return hit ? this.accept(hit.state, hit.proposal) : false;
|
||||
}
|
||||
/** Reject by proposal id (test-facing twin of the thread-menu gesture). */
|
||||
rejectById(docPath: string, proposalId: string): boolean {
|
||||
const hit = this.byId(docPath, proposalId);
|
||||
if (!hit) return false;
|
||||
this.reject(hit.state, hit.proposal);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async acceptThread(vsThread: vscode.CommentThread): Promise<void> {
|
||||
const hit = this.byThread(vsThread);
|
||||
if (hit) await this.accept(hit.state, hit.proposal);
|
||||
}
|
||||
private rejectThread(vsThread: vscode.CommentThread): void {
|
||||
const hit = this.byThread(vsThread);
|
||||
if (hit) this.reject(hit.state, hit.proposal);
|
||||
}
|
||||
|
||||
private async accept(state: DocState, proposal: Proposal): Promise<boolean> {
|
||||
const document = this.openDoc(state);
|
||||
if (!document) return false;
|
||||
// INV-11: the fingerprint-guard — exact re-resolve at decision time.
|
||||
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
|
||||
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
|
||||
if (resolved === "orphaned") {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).",
|
||||
);
|
||||
this.renderAll(document);
|
||||
return false;
|
||||
}
|
||||
const range = new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end));
|
||||
// No awaits between resolve and the seam call: document.version is current.
|
||||
const ok = await this.attribution.applyAgentEdit(document, range, proposal.replacement, proposal.author, {
|
||||
expectedVersion: document.version,
|
||||
turnId: proposal.turnId,
|
||||
});
|
||||
if (!ok) {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: the editor rejected the accept — the proposal is still pending.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
|
||||
this.renderAll(document);
|
||||
return true;
|
||||
}
|
||||
|
||||
private reject(state: DocState, proposal: Proposal): void {
|
||||
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
|
||||
const document = this.openDoc(state);
|
||||
if (document) this.renderAll(document);
|
||||
}
|
||||
|
||||
// ---- PUC-4: load / external change / resolve-or-flag --------------------------------
|
||||
|
||||
/** Load + (re)render every pending proposal at its resolved anchor (or flagged). */
|
||||
renderAll(document: vscode.TextDocument): void {
|
||||
if (!this.isTracked(document)) return;
|
||||
const docPath = this.docPathOf(document.uri);
|
||||
const state = this.ensureState(document);
|
||||
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
|
||||
for (const vsThread of state.vsThreads.values()) vsThread.dispose();
|
||||
state.vsThreads.clear();
|
||||
state.live.clear();
|
||||
state.unresolved.clear();
|
||||
const text = document.getText();
|
||||
for (const proposal of state.artifact.proposals) {
|
||||
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
|
||||
const resolved = fp ? resolve(text, fp) : "orphaned";
|
||||
if (resolved === "orphaned") {
|
||||
const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0;
|
||||
const off = document.offsetAt(new vscode.Position(line, 0));
|
||||
this.renderProposal(document, state, proposal, { start: off, end: off }, false);
|
||||
} else {
|
||||
this.renderProposal(document, state, proposal, resolved, true);
|
||||
}
|
||||
}
|
||||
this.renderDecorations(document, state);
|
||||
this.renderStatus(state);
|
||||
}
|
||||
|
||||
/** Shared-watcher entry point (extension.ts): a sidecar changed externally. */
|
||||
handleExternalSidecarChange(uri: vscode.Uri): void {
|
||||
for (const state of this.docs.values()) {
|
||||
if (this.store.sidecarPath(state.docPath) === uri.fsPath) {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);
|
||||
if (doc) this.renderAll(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||
const state = this.docs.get(this.docPathOf(e.document.uri));
|
||||
if (!state || state.live.size === 0) return;
|
||||
for (const change of e.contentChanges) {
|
||||
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
|
||||
for (const [id, range] of state.live) {
|
||||
const next = shift(range, edit);
|
||||
state.live.set(id, next);
|
||||
const vsThread = state.vsThreads.get(id);
|
||||
if (vsThread && !state.unresolved.has(id)) {
|
||||
vsThread.range = new vscode.Range(e.document.positionAt(next.start), e.document.positionAt(next.end));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Live shift keeps the UI following; staleness is judged at decision time
|
||||
// (accept re-resolves, INV-11) and at the next renderAll.
|
||||
this.renderDecorations(e.document, state);
|
||||
}
|
||||
|
||||
// ---- rendering -----------------------------------------------------------------------
|
||||
|
||||
private renderProposal(
|
||||
document: vscode.TextDocument,
|
||||
state: DocState,
|
||||
proposal: Proposal,
|
||||
offsets: OffsetRange,
|
||||
pending: boolean,
|
||||
): void {
|
||||
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
|
||||
const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end));
|
||||
const vsThread = this.controller.createCommentThread(document.uri, range, [
|
||||
{
|
||||
body: new vscode.MarkdownString(proposalBody(fp?.text ?? "", proposal)),
|
||||
mode: vscode.CommentMode.Preview,
|
||||
author: { name: proposal.author.id },
|
||||
},
|
||||
]);
|
||||
vsThread.label = pending
|
||||
? "Pending proposal"
|
||||
: "⚠ Stale proposal (target text changed or missing) — accept disabled";
|
||||
vsThread.contextValue = pending ? "pending" : "unresolved";
|
||||
vsThread.collapsibleState = pending
|
||||
? vscode.CommentThreadCollapsibleState.Expanded
|
||||
: vscode.CommentThreadCollapsibleState.Collapsed;
|
||||
state.vsThreads.set(proposal.id, vsThread);
|
||||
state.live.set(proposal.id, offsets);
|
||||
if (!pending) state.unresolved.add(proposal.id);
|
||||
}
|
||||
|
||||
private renderDecorations(document: vscode.TextDocument, state: DocState): void {
|
||||
const ranges: vscode.Range[] = [];
|
||||
for (const [id, off] of state.live) {
|
||||
if (state.unresolved.has(id)) continue;
|
||||
ranges.push(new vscode.Range(document.positionAt(off.start), document.positionAt(off.end)));
|
||||
}
|
||||
for (const editor of vscode.window.visibleTextEditors) {
|
||||
if (editor.document === document) editor.setDecorations(this.pendingType, ranges);
|
||||
}
|
||||
}
|
||||
|
||||
private renderStatus(state: DocState): void {
|
||||
const n = state.unresolved.size;
|
||||
if (n === 0) {
|
||||
this.statusItem.hide();
|
||||
return;
|
||||
}
|
||||
this.statusItem.text = `$(warning) ${n} stale proposal${n === 1 ? "" : "s"}`;
|
||||
this.statusItem.tooltip =
|
||||
"Cowriting: proposals whose target text changed or is missing — undo to restore, or reject";
|
||||
this.statusItem.show();
|
||||
}
|
||||
|
||||
// ---- lookups ----------------------------------------------------------------------------
|
||||
|
||||
private openDoc(state: DocState): vscode.TextDocument | undefined {
|
||||
return vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);
|
||||
}
|
||||
private byThread(vsThread: vscode.CommentThread): { state: DocState; proposal: Proposal } | undefined {
|
||||
for (const state of this.docs.values()) {
|
||||
for (const [id, t] of state.vsThreads) {
|
||||
if (t === vsThread) {
|
||||
const proposal = state.artifact.proposals.find((p) => p.id === id);
|
||||
if (proposal) return { state, proposal };
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
private byId(docPath: string, proposalId: string): { state: DocState; proposal: Proposal } | undefined {
|
||||
const state = this.docs.get(docPath);
|
||||
const proposal = state?.artifact.proposals.find((p) => p.id === proposalId);
|
||||
return state && proposal ? { state, proposal } : undefined;
|
||||
}
|
||||
|
||||
// ---- test-facing surface ------------------------------------------------------------------
|
||||
|
||||
getRendered(docPath: string): RenderedProposal[] {
|
||||
const state = this.docs.get(docPath);
|
||||
if (!state) return [];
|
||||
const out: RenderedProposal[] = [];
|
||||
for (const [id] of state.vsThreads) {
|
||||
const p = state.artifact.proposals.find((x) => x.id === id)!;
|
||||
const off = state.live.get(id)!;
|
||||
out.push({
|
||||
id,
|
||||
pending: !state.unresolved.has(id),
|
||||
turnId: p.turnId,
|
||||
range: { start: off.start, end: off.end },
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
getStaleCount(docPath: string): number {
|
||||
return this.docs.get(docPath)?.unresolved.size ?? 0;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user