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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user