From 4c947acc43bf127bc9b3e90e595c9d89180623f4 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 23:26:11 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20proposal=20threads=20disable=20the=20rep?= =?UTF-8?q?ly=20input=20=E2=80=94=20decide-only,=20INV-12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playing with F4, the operator hit VS Code's default "Reply…" box on a proposal thread: no submit command is wired for cowriting.proposals, so typed text had nowhere to go. Proposals are decide-only (accept/reject in the title bar); discussion belongs in a coauthoring thread. Set canReply=false so the dead input never renders, and expose canReply on the test-facing RenderedProposal with an E2E assertion. Co-Authored-By: Claude Opus 4.8 --- src/proposalController.ts | 10 +++++++++- test/e2e/suite/proposals.test.ts | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/proposalController.ts b/src/proposalController.ts index 139c096..6ae714b 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -22,6 +22,8 @@ export interface RenderedProposal { id: string; /** true → resolves exactly, decidable; false → stale/orphaned (INV-11). */ pending: boolean; + /** always false — proposals are decide-only, no reply input (INV-12). */ + canReply: boolean; turnId?: string; range: { start: number; end: number }; } @@ -252,6 +254,11 @@ export class ProposalController implements vscode.Disposable { 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"; @@ -315,12 +322,13 @@ export class ProposalController implements vscode.Disposable { const state = this.docs.get(docPath); if (!state) return []; const out: RenderedProposal[] = []; - for (const [id] of state.vsThreads) { + for (const [id, vsThread] of state.vsThreads) { 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 }, }); diff --git a/test/e2e/suite/proposals.test.ts b/test/e2e/suite/proposals.test.ts index 0ba7c9c..19d4e61 100644 --- a/test/e2e/suite/proposals.test.ts +++ b/test/e2e/suite/proposals.test.ts @@ -74,6 +74,7 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => { assert.strictEqual(rendered.length, 1); assert.strictEqual(rendered[0].pending, true); assert.strictEqual(rendered[0].turnId, "turn-p1"); + assert.strictEqual(rendered[0].canReply, false, "proposals are decide-only — no dead reply input (INV-12)"); const art = readSidecar(); assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time"); assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, TARGET); -- 2.39.5