fix: restore proposals + attribution on coediting enter (PUC-7) — registry onDidChange subscribers
Task 4 gated every surface on CoeditingRegistry but only wired ThreadController/EditorProposalController to registry.onDidChange for restore-on-enter — ProposalController.renderAll and AttributionController.loadAll were never called on the enter transition, so a document's FIRST enter with pre-existing sidecar proposals/attribution showed threads but not proposal decorations/CodeLens or committed author-coloring until a later edit happened to fire them (a gap Task 4's own report flagged as known and unfixed). Both controllers now subscribe to registry.onDidChange in their constructors and call renderAll/loadAll on entry only (both already self-gate on isCoediting; exit stays hide-only, mirroring ThreadController). Construction order in extension.ts (attribution before proposals before EditorProposalController) keeps the same-tick read order correct. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>("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<string, unknown> }).docs.delete(key);
|
||||
(api.attributionController as unknown as { docs: Map<string, unknown> }).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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user