Native surfaces migration — evolve the extension onto VS Code's native review surfaces (9-task plan, spec v0.2.1) (#72)

This commit was merged in pull request #72.
This commit is contained in:
2026-07-02 23:09:37 +00:00
parent 93eeaf13b8
commit 935fcc35ee
54 changed files with 3689 additions and 1995 deletions
+39 -7
View File
@@ -21,6 +21,7 @@ import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath";
import type { AuthorSpan } from "./trackChangesModel";
import type { CoeditingRegistry } from "./coeditingRegistry";
/** Test-facing snapshot of live attribution state for a document. */
export interface RenderedSpan {
@@ -56,8 +57,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;
@@ -66,12 +70,35 @@ export class AttributionController implements vscode.Disposable {
private readonly store: SidecarRouter,
private readonly rootDir: string | undefined,
private readonly guard: VersionGuard,
private readonly registry: CoeditingRegistry,
) {
this.disposables.push(this.statusItem, this.output, this.applyEmitter);
this.disposables.push(
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
vscode.window.onDidChangeActiveTextEditor(() => this.renderActive()),
// PUC-7 restore-on-enter: `loadAll` is the only path that (re)populates
// s.spans/s.orphans from the sidecar — without this, a document's FIRST
// entry into coediting (registry.enter(), extension.ts) never runs it,
// so pre-existing committed authorship never surfaces until the next
// edit. Mirrors ThreadController's own registry subscription; `loadAll`
// already gates on isCoediting, and exit intentionally does nothing here
// (no data to wipe — EditorProposalController's own gate clears the
// decorations that read spansFor). Registered before ProposalController/
// EditorProposalController (construction order in extension.ts) so their
// renders see fresh spans on the same enter transition.
this.registry.onDidChange(({ uri, coediting }) => {
if (!coediting) {
// Finding 5 (final whole-branch review): exit hides the status-bar
// item too — otherwise a stale "N orphaned attributions" warning
// for a doc that's no longer even coediting stays pinned in the bar
// until some unrelated render happens to fire for it.
if (vscode.window.activeTextEditor?.document.uri.toString() === uri) this.statusItem.hide();
return;
}
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) this.loadAll(doc);
}),
);
}
@@ -101,6 +128,7 @@ export class AttributionController implements vscode.Disposable {
/** Load the sidecar and re-resolve every attribution (live span | orphan). */
loadAll(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return;
if (!this.registry.isCoediting(document.uri)) return;
const docPath = this.keyOf(document);
const s = this.state(docPath);
const artifact = this.store.load(docPath);
@@ -142,6 +170,7 @@ export class AttributionController implements vscode.Disposable {
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
if (!this.registry.isCoediting(e.document.uri)) return;
const docPath = this.keyOf(e.document);
if (!e.document.isDirty && this.matchesDisk(e.document)) {
// Disk sync (revert / external reload): buffer now equals the file on
@@ -291,9 +320,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 +332,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 });