fix: proposal threads disable the dead reply input (decide-only, INV-12) #16

Merged
benstull merged 1 commits from fix/proposal-canreply into main 2026-06-11 06:26:34 +00:00
2 changed files with 10 additions and 1 deletions
Showing only changes of commit 4c947acc43 - Show all commits
+9 -1
View File
@@ -22,6 +22,8 @@ export interface RenderedProposal {
id: string; id: string;
/** true → resolves exactly, decidable; false → stale/orphaned (INV-11). */ /** true → resolves exactly, decidable; false → stale/orphaned (INV-11). */
pending: boolean; pending: boolean;
/** always false — proposals are decide-only, no reply input (INV-12). */
canReply: boolean;
turnId?: string; turnId?: string;
range: { start: number; end: number }; range: { start: number; end: number };
} }
@@ -252,6 +254,11 @@ export class ProposalController implements vscode.Disposable {
author: { name: proposal.author.id }, 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 vsThread.label = pending
? "Pending proposal" ? "Pending proposal"
: "⚠ Stale proposal (target text changed or missing) — accept disabled"; : "⚠ 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); const state = this.docs.get(docPath);
if (!state) return []; if (!state) return [];
const out: RenderedProposal[] = []; 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 p = state.artifact.proposals.find((x) => x.id === id)!;
const off = state.live.get(id)!; const off = state.live.get(id)!;
out.push({ out.push({
id, id,
pending: !state.unresolved.has(id), pending: !state.unresolved.has(id),
canReply: vsThread.canReply !== false,
turnId: p.turnId, turnId: p.turnId,
range: { start: off.start, end: off.end }, range: { start: off.start, end: off.end },
}); });
+1
View File
@@ -74,6 +74,7 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
assert.strictEqual(rendered.length, 1); assert.strictEqual(rendered.length, 1);
assert.strictEqual(rendered[0].pending, true); assert.strictEqual(rendered[0].pending, true);
assert.strictEqual(rendered[0].turnId, "turn-p1"); 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(); const art = readSidecar();
assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time"); assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time");
assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, TARGET); assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, TARGET);