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 06:58:22 +00:00
parent 360989ca2c
commit 1e50007c50
3 changed files with 79 additions and 36 deletions
@@ -44,6 +44,7 @@ import {
import { membershipHref } from "@/lib/membership";
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
import {
rectificationAdoptingLabel,
RECTIFICATION_EMPTY_ACTION_LABEL,
RECTIFICATION_EMPTY_COPY,
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
@@ -475,7 +476,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const [choiceNonce, setChoiceNonce] = useState(0);
const choiceContinuationPending = useRef(false);
const conversation = useRef<HTMLElement>(null);
const workspace = useRef<HTMLDivElement>(null);
const composer = useRef<HTMLTextAreaElement>(null);
@@ -702,34 +702,52 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
snapshotAbort.current?.abort();
}, []);
const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => {
/**
* Run one agent turn. A `continuation` reuses the live row an earlier step
* (a tapped choice, an adopted candidate) already put at the end of the
* transcript, so the reader sees one uninterrupted working row from the tap
* to the next question instead of a gap and a re-appended row. The caller
* of a continuation already holds `busy`.
*/
const send = useCallback(async (
action: "opening" | "message" | "read_only",
messageText: string,
continuation?: Readonly<{ reuseAssistantRenderKey: string; label: string }>,
) => {
const trimmed = action === "message" ? messageText.trim() : "";
if ((action === "message" && !trimmed) || busy || readonly) return;
if ((action === "message" && !trimmed) || readonly) return;
if (!continuation && busy) return;
setError("");
setPending(true);
const initialLabel = rectificationInitialLiveLabel(action);
const initialLabel = rectificationInitialLiveLabel(action, continuation?.label);
beginLiveRun(initialLabel);
keyCounter.current += 1;
const requestId = globalThis.crypto.randomUUID();
const turnKey = keyCounter.current;
const userRenderKey = `v9-user-${turnKey}`;
const assistantRenderKey = `v9-assistant-${turnKey}`;
const assistantRenderKey = continuation?.reuseAssistantRenderKey ?? `v9-assistant-${turnKey}`;
const pendingFocusId = currentQuestionRef.current?.focus_id;
setMessages((current) => [
...(action === "message" && pendingFocusId
? markQuestionAnswered(current, pendingFocusId, "typed")
: current),
...(action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: {
phase: "evidence-validation",
label: initialLabel,
startedAt: Date.now(),
} },
]);
const liveRow: RenderMessage = {
role: "assistant",
text: "",
renderKey: assistantRenderKey,
state: "thinking",
activityTrace: emptyActivityTrace(),
activity: { phase: "evidence-validation", label: initialLabel, startedAt: Date.now() },
};
setMessages((current) => (continuation
? current.map((message) => (message.renderKey === assistantRenderKey ? { ...liveRow, activity: message.activity ?? liveRow.activity } : message))
: [
...(action === "message" && pendingFocusId
? markQuestionAnswered(current, pendingFocusId, "typed")
: current),
...(action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
liveRow,
]));
setDraft("");
let raw = "";
@@ -1056,7 +1074,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
keyCounter.current += 1;
const turnKey = keyCounter.current;
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
beginLiveRun(RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer);
const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer;
beginLiveRun(recordingLabel);
setMessages((current) => [
...markQuestionAnswered(current, focusId, optionId),
{
@@ -1067,7 +1086,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activityTrace: emptyActivityTrace(),
activity: {
phase: "evidence-validation",
label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer,
label: recordingLabel,
startedAt: Date.now(),
},
},
@@ -1108,11 +1127,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const snapshot = await loadCaseSnapshot();
const turns = snapshot?.turns ?? [];
if (willContinue) {
setMessages((current) => mergeTurnQuestions(
current.filter((message) => message.renderKey !== assistantRenderKey),
turns,
));
choiceContinuationPending.current = true;
// The follow-up turn continues on the row already in place: no removed
// row, no effect hop, and `busy` never drops in between (so the next
// card cannot flash before the turn hides it).
setMessages((current) => mergeTurnQuestions(current, turns));
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
} else {
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
@@ -1155,16 +1174,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onMessagesChange,
onProfileIncomplete,
readonly,
send,
sessionId,
setPending,
]);
useEffect(() => {
if (!choiceContinuationPending.current || busy || readonly) return;
choiceContinuationPending.current = false;
void send("read_only", "");
}, [busy, readonly, send]);
useEffect(() => {
if (initialTurns.length > 0) {
if (shouldStartOpening) onOpeningConsumed?.();
@@ -1181,6 +1195,24 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setError("");
setAcceptingCandidateId(candidateId);
setPending(true);
// Adoption is a server round trip followed by an agent turn; the reader sees
// one live row for the whole of it instead of a greyed button.
const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? "";
const adoptingLabel = rectificationAdoptingLabel(candidateTime);
keyCounter.current += 1;
const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`;
beginLiveRun(adoptingLabel);
setMessages((current) => [
...current,
{
role: "assistant",
text: "",
renderKey: assistantRenderKey,
state: "thinking",
activityTrace: emptyActivityTrace(),
activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() },
},
]);
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`,
@@ -1228,14 +1260,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onSaved?.(payload.saved_time, "accepted");
onCompleted?.();
await loadCaseSnapshot();
choiceContinuationPending.current = true;
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel });
} catch (caught) {
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
} finally {
setAcceptingCandidateId(null);
setPending(false);
}
}, [acceptingCandidateId, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, sessionId, setPending]);
}, [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
async function copyMessage(message: RenderMessage) {
try {
@@ -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(
@@ -1198,7 +1198,10 @@ test("the public agent route treats structured choice as a non-model command", (
assert.match(chat, /isPersistedFocusId\(focusId\)/);
assert.match(chat, /focusId,/);
assert.match(chat, /shouldContinueAfterStructuredChoice\(payload\?\.nextAction, payload\)/);
assert.match(chat, /send\("read_only", ""\)/);
// Was: /send\("read_only", ""\)/ — the bare follow-up call from an effect hop. The
// follow-up now continues on the live row already in place, so the call carries
// that row (BUG-506); the route contract (read_only after a structured choice) holds.
assert.match(chat, /send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel \}\)/);
assert.match(chat, /willContinue/);
assert.match(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\)/);
// Former locks: `回到最新` and `followTailRef.current` — the rectification-only chip and follow