F10: interactive track-changes review in the markdown preview (#29) #30

Merged
benstull merged 17 commits from f10-interactive-review into main 2026-06-12 07:39:51 +00:00
Showing only changes of commit 743de78994 - Show all commits
+16 -103
View File
@@ -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<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(
@@ -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<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> {
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;
}