diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 09a7a494..6159add0 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -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(null); const [regeneratingMessageKey, setRegeneratingMessageKey] = useState(null); const [choiceNonce, setChoiceNonce] = useState(0); - const choiceContinuationPending = useRef(false); const conversation = useRef(null); const workspace = useRef(null); const composer = useRef(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 { diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 442e4246..59b5560b 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -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( diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index 94606fa1..8b8a61d6 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -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