diff --git a/src/attributionController.ts b/src/attributionController.ts index 528a67e..5b96950 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -77,6 +77,21 @@ export class AttributionController implements vscode.Disposable { 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) return; + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); + if (doc) this.loadAll(doc); + }), ); } diff --git a/src/proposalController.ts b/src/proposalController.ts index 36373a5..bbc5177 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -63,6 +63,23 @@ export class ProposalController implements vscode.Disposable { this.disposables.push(this.onDidChangeProposalsEmitter); this.disposables.push( vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), + // PUC-7 restore-on-enter (mirrors ThreadController/AttributionController): + // `renderAll` is the only path that recomputes state.live/unresolved AND + // fires onDidChangeProposals — the event EditorProposalController listens + // to for optimistic-apply (decorations/CodeLens). Without this, a FIRST + // entry into coediting (registry.enter()) never fires it, so pre-existing + // pending proposals stay un-applied/undecorated until the next edit. + // `renderAll` already gates on isCoediting; exit intentionally does + // nothing here (it clears only the live/unresolved recompute, not the + // persisted artifact — EditorProposalController's own gate hides the + // decorations). Registered after AttributionController's own subscription + // (construction order in extension.ts) so decorateCommitted's spansFor + // read sees fresh attribution on the same enter transition. + this.registry.onDidChange(({ uri, coediting }) => { + if (!coediting) return; + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); + if (doc) this.renderAll(doc); + }), ); } diff --git a/test/e2e/suite/coediting.test.ts b/test/e2e/suite/coediting.test.ts index 3c2fe0d..a9b885a 100644 --- a/test/e2e/suite/coediting.test.ts +++ b/test/e2e/suite/coediting.test.ts @@ -1,6 +1,10 @@ import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; import * as vscode from "vscode"; -import { activateApi, settle } from "./helpers"; +import { activateApi, settle, settleUntil } from "./helpers"; + +const WS = process.env.E2E_WORKSPACE!; async function api() { const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; @@ -55,4 +59,82 @@ suite("coediting registry (PUC-7)", () => { await settle(); assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 1); }); + + // PUC-7 restore-on-enter gap: unlike ThreadController (Task 4, above), + // ProposalController.renderAll and AttributionController.loadAll were never + // wired to registry.onDidChange — so a document's FIRST enter into + // coediting with pre-existing sidecar proposals/attribution showed threads + // but not proposal decorations/CodeLens or committed author-coloring until + // a later edit happened to fire renderAll/loadAll. A same-session + // exit-then-re-enter doesn't reproduce this on its own (nothing clears the + // in-memory render caches on exit), so this test wipes those caches after + // exit — simulating "never rendered in this session yet" — then re-enters + // and asserts BOTH surfaces restore via the registry.onDidChange path + // alone, with no subsequent edit. + test("re-enter after exit restores proposal + attribution surfaces without an edit (PUC-7)", async () => { + const api = await activateApi(); + // A workspace FILE (not untitled) — attribution only persists to the + // sidecar on save, and this test needs the sidecar (not just in-memory + // live state) to hold the seeded data before re-entering. + const rel = "docs/puc7-restore.md"; + const abs = path.join(WS, rel); + fs.writeFileSync(abs, "# t\n\nfoo bar baz\n", "utf8"); + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs)); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settle(); + + // Seed a pending proposal (F4) + a committed agent-attributed edit (F3) — + // the two surfaces the gap left un-restored. + const target = "bar"; + const start = doc.getText().indexOf(target); + const proposalId = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { + uri: doc.uri.toString(), + start, + end: start + target.length, + newText: "BAR-PROPOSED", + model: "sonnet", + sessionId: "e2e-puc7", + turnId: "turn-puc7", + }); + assert.ok(proposalId, "propose returns an id"); + // A real text change (not a no-op replace) — "foo" precedes the "bar" + // target above, so it is unaffected by F12's optimistic-apply of the + // proposal into the buffer. + const fooStart = doc.getText().indexOf("foo"); + const applied = await api.attributionController.applyAgentEdit( + doc, + new vscode.Range(doc.positionAt(fooStart), doc.positionAt(fooStart + "foo".length)), + "FOO-CLAUDE", + { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-puc7" } }, + { turnId: "turn-puc7" }, + ); + assert.strictEqual(applied, true, "seam edit applies"); + await settle(); + await doc.save(); // persists attribution records to the sidecar (F3 PUC-4) + + const key = api.proposalController.keyFor(doc); + assert.ok(api.proposalController.getRendered(key).length >= 1, "sanity: proposal rendered pre-exit"); + assert.ok(api.attributionController.getSpans(key).length >= 1, "sanity: attribution rendered pre-exit"); + + await vscode.commands.executeCommand("cowriting.stopCoediting"); + + // Wipe the controllers' in-memory render caches for this doc — nothing in + // production does this on exit (by design: exit hides, it does not wipe + // sidecar-backed state), so this reflection reproduces the "never + // rendered this session" precondition of a genuinely fresh first-enter. + (api.proposalController as unknown as { docs: Map }).docs.delete(key); + (api.attributionController as unknown as { docs: Map }).docs.delete(key); + assert.strictEqual(api.proposalController.getRendered(key).length, 0, "cache cleared pre-re-enter"); + assert.strictEqual(api.attributionController.getSpans(key).length, 0, "cache cleared pre-re-enter"); + + // Re-enter — the registry.onDidChange subscribers (PUC-7 fix) must + // restore both surfaces from the sidecar with no subsequent edit. + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settleUntil( + () => api.proposalController.getRendered(key).length >= 1 && api.attributionController.getSpans(key).length >= 1, + ); + assert.strictEqual(api.proposalController.getRendered(key).length, 1, "proposal restored on re-enter"); + assert.ok(api.attributionController.getSpans(key).length >= 1, "attribution restored on re-enter"); + }); });