fix(rectification): keep one live row from a tapped choice or an adopted candidate through the follow-up turn

After a choice was recorded the chat removed its "正在记录本次选择…" row,
dropped `busy`, and let an effect start the follow-up turn a frame later —
one blank frame, and the next card could flash before the turn hid it.
Adopting a candidate showed nothing but a greyed button. `send` now takes a
continuation that reuses an existing live row, the choice and adoption
flows await it in the same async chain, `busy` is held throughout, and
adoption puts "正在采用 HH:MM…" at the end of the transcript before the
round trip.

BUG-506

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-03 07:07:31 +00:00
co-authored by Claude Fable 5.1
parent 360989ca2c
commit 1e50007c50
3 changed files with 79 additions and 36 deletions
@@ -230,9 +230,16 @@ test("candidate acceptance is non-billable, mutually exclusive, and continues th
assert.match(chat, /resultId: candidateResult\.resultId/);
assert.match(chat, /if \(!candidateResult \|\| acceptingCandidateId \|\| busy \|\| readonly\) return;/);
assert.match(chat, /setAcceptingCandidateId\(candidateId\);[\s\S]*setPending\(true\);/);
assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*choiceContinuationPending\.current = true;/);
// Was: `await loadCaseSnapshot(); choiceContinuationPending.current = true;` and an
// effect `if (!choiceContinuationPending.current || busy || readonly) return; void
// send("read_only", "")`. That hop dropped `busy` for a frame and removed the live
// row, so the next card flashed and the transcript went blank between the tap and
// the follow-up turn. Continuation now runs in the same async chain on the same
// row (BUG-506). The old `doesNotMatch` guarded against a *bare* `send("read_only",
// "")` after the snapshot — a continuation carries the row to reuse, and is kept.
assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel \}\);/);
assert.match(chat, /finally \{[\s\S]*setAcceptingCandidateId\(null\);[\s\S]*setPending\(false\);/);
assert.match(chat, /if \(!choiceContinuationPending\.current \|\| busy \|\| readonly\) return;[\s\S]*void send\("read_only", ""\);/);
assert.doesNotMatch(chat, /choiceContinuationPending/);
assert.doesNotMatch(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", ""\);/);
assert.doesNotMatch(chat, /action: "accept_candidate"/);
const acceptRoute = readFileSync(