From 743de78994a8d333f055454d40be91479f634004 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 23:59:50 -0700 Subject: [PATCH] F10 SLICE-1: remove F4 in-editor proposal threads/decorations; bookkeeping via live/unresolved (#29) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/proposalController.ts | 119 +++++--------------------------------- 1 file changed, 16 insertions(+), 103 deletions(-) diff --git a/src/proposalController.ts b/src/proposalController.ts index 14ed4a4..c65530b 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -4,7 +4,8 @@ * 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), + * anchor bookkeeping into state.live/state.unresolved (no in-editor UI — + * F10/INV-32 makes the rendered preview the single review surface), * 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. @@ -13,7 +14,7 @@ import * as vscode from "vscode"; import { SidecarRouter, docIdentity } from "./sidecarRouter"; 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 { addProposal, removeProposal } from "./proposalModel"; import type { AttributionController } from "./attributionController"; import type { VersionGuard } from "./versionGuard"; import { isAuthorable } from "./workspacePath"; @@ -33,24 +34,15 @@ interface DocState { docPath: string; uri: vscode.Uri; artifact: Artifact; - vsThreads: Map; /** proposal id -> live offset range (within-session optimization, INV-3). */ live: Map; /** proposal ids whose anchor did not resolve at last render (stale/orphaned). */ unresolved: Set; } -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(); // keyed by docPath - private readonly pendingType = vscode.window.createTextEditorDecorationType(PENDING_DECO); private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 89); constructor( @@ -59,13 +51,8 @@ export class ProposalController implements vscode.Disposable { private readonly rootDir: string | undefined, private readonly guard: VersionGuard, ) { - // 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(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)), ); } @@ -85,7 +72,6 @@ export class ProposalController implements vscode.Disposable { docPath, uri: document.uri, artifact: this.store.load(docPath) ?? emptyArtifact(docPath), - vsThreads: new Map(), live: new Map(), unresolved: new Set(), }; @@ -134,15 +120,6 @@ export class ProposalController implements vscode.Disposable { return true; } - private async acceptThread(vsThread: vscode.CommentThread): Promise { - 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 { if (this.guard.isReadOnly(state.docPath)) return false; const document = this.openDoc(state); @@ -189,8 +166,6 @@ export class ProposalController implements vscode.Disposable { const docPath = this.keyOf(document); 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(); @@ -200,12 +175,11 @@ export class ProposalController implements vscode.Disposable { 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); + this.recordProposal(state, proposal, { start: off, end: off }, false); } else { - this.renderProposal(document, state, proposal, resolved, true); + this.recordProposal(state, proposal, resolved, true); } } - this.renderDecorations(document, state); this.renderStatus(state); } @@ -224,66 +198,23 @@ export class ProposalController implements vscode.Disposable { 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)); - } - } + for (const [id, range] of state.live) state.live.set(id, shift(range, edit)); } - // 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 }, - }, - ]); - // Proposals are decide-only (INV-12): ✓ accept / ✗ reject in the title - // bar. Without this, VS Code renders its default "Reply…" input with no - // submit command wired — a dead end. Discussion belongs in a regular - // coauthoring thread; proposal discussion trails are deferred (spec §1.7). - vsThread.canReply = false; - 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); + /** + * Record a proposal's resolved anchor in the live/unresolved bookkeeping. + * No editor UI (F10/INV-32: the rendered preview is the single review + * surface) — this keeps state.live/state.unresolved populated so the preview + * (SLICE-3) and the getRendered test seam can read it. + */ + private recordProposal(state: DocState, proposal: Proposal, offsets: OffsetRange, pending: boolean): void { 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) { @@ -301,17 +232,6 @@ export class ProposalController implements vscode.Disposable { private openDoc(state: DocState): vscode.TextDocument | undefined { return vscode.workspace.textDocuments.find((d) => this.keyOf(d) === 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); @@ -324,16 +244,9 @@ export class ProposalController implements vscode.Disposable { const state = this.docs.get(docPath); if (!state) return []; const out: RenderedProposal[] = []; - for (const [id, vsThread] of state.vsThreads) { + for (const [id, off] of state.live) { const p = state.artifact.proposals.find((x) => x.id === id)!; - const off = state.live.get(id)!; - out.push({ - id, - pending: !state.unresolved.has(id), - canReply: vsThread.canReply !== false, - turnId: p.turnId, - range: { start: off.start, end: off.end }, - }); + out.push({ id, pending: !state.unresolved.has(id), canReply: false, turnId: p.turnId, range: { start: off.start, end: off.end } }); } return out; }