Files
Jyotisha/frontend/src/lib/rectification-chat-choice-run.ts
T
Jesse_ChenandClaude Opus 5.5 3a66c39f48 refactor(rectification): split the chat component into hooks, parameter functions and blocks
Move-only (TASK-rectification-code-split-20260926 T1/T4). No behavior, API,
copy, DB, Skill or scoring change.

- rectification-agentic-chat.tsx: 2043 -> 737 lines; component body
  1625 -> 630; useState 35 -> 12, useRef 16 -> 9, effects 9 -> 5,
  useCallback 12 -> 6.
- Snapshot sync -> hooks/use-rectification-case-snapshot.ts; board layout
  and live label clock -> two small hooks.
- send / submitStructuredChoice / acceptCandidate bodies ->
  lib/rectification-chat-{turn,choice,accept}-run.ts parameter functions
  (bodies verbatim, deps destructured to the same names); copy/regenerate,
  question repair, pure transcript and snapshot helpers and the per-render
  view derivation -> lib/rectification-chat-*.ts (no React hooks).
- Question-gap blocks and the read-only range line ->
  components/rectification-question-gap-notices.tsx.
- Dependency arrays kept exactly as before (lint warnings +3, listed in
  PROGRESS); no dep added to appease the linter.
- Tests: whole-source contracts read tests/rectification-chat-surface.ts
  (container + split files, like home-surface.ts); slices of moved code now
  call the extracted functions or render the extracted block, each with a
  原值/新值/原因 comment. New tests/rectification-growth-contract.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 19:23:52 +08:00

284 lines
10 KiB
TypeScript

/**
* A tapped choice (answer, stop, skip this probe) from the rectification chat:
* the local answered mark and live row, the structured POST, the transcript
* merge after the Case re-read, the follow-up turn on the same row, and the
* roll-back when the tap did not land (BUG-1046). Moved out of
* RectificationAgenticChat's `submitStructuredChoice` unchanged
* (TASK-rectification-code-split-20260926). A parameter function: the
* component hands in its state, setters and refs; no React hooks here.
*/
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import type { ChatMessage } from "@/lib/chat-message-view";
import { emptyActivityTrace } from "@/lib/agent-activity-trace";
import { RECTIFICATION_ACTIVITY_PROGRESS_LABELS } from "@/lib/rectification-activity-labels";
import {
RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY,
isAbortError,
} from "@/lib/rectification-surface-state";
import {
CHOICE_ACTION,
SKIP_PROBE_ACTION,
STOP_ACTION,
shouldContinueAfterStructuredChoice,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { isPersistedFocusId, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import {
appendUnseenAssistantTurns,
applySnapshotTurnsToMessages,
mergeTurnQuestions,
} from "@/lib/rectification-snapshot-messages";
import {
markQuestionAnswered,
messagesFromTurns,
withdrawAnswerMark,
type PersistedTurn,
} from "@/lib/rectification-chat-messages";
import type { CurrentQuestionModel } from "@/lib/rectification-chat-snapshot";
import type {
RectificationRunOutcome,
RectificationSnapshotLoader,
RectificationTurnAction,
RectificationTurnContinuation,
} from "@/lib/rectification-chat-turn-run";
import type { RenderMessage } from "@/components/rectification-message-entry";
export type RectificationStructuredChoiceAction = typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION;
export type RectificationStructuredChoiceOverride = Readonly<{
focusId?: string;
questionId?: string | null;
probeId?: string | null;
caseRevision?: number | null;
}>;
export type RectificationStructuredChoiceDeps = Readonly<{
caseId: string;
sessionId: string;
busy: boolean;
readonly: boolean;
choiceCard: ChoiceCardModel | null;
currentQuestion: CurrentQuestionModel | null;
keyCounter: MutableRefObject<number>;
runAbort: MutableRefObject<AbortController | null>;
lastRunOutcome: MutableRefObject<RectificationRunOutcome>;
conversationAnchor: Readonly<{ pinLatestTurn: () => void }>;
actionIdForChoice: (focusId: string, optionId: ChoiceOptionId) => string;
setError: Dispatch<SetStateAction<string>>;
setMessages: Dispatch<SetStateAction<RenderMessage[]>>;
setChoiceNonce: Dispatch<SetStateAction<number>>;
setNextUserActionId: Dispatch<SetStateAction<string | null>>;
setPending: (value: boolean) => void;
beginLiveRun: (baseLabel: string) => void;
loadCaseSnapshot: RectificationSnapshotLoader;
send: (
action: RectificationTurnAction,
messageText: string,
continuation?: RectificationTurnContinuation,
) => Promise<void>;
onCompleted?: () => void;
onMessagesChange?: (messages: ChatMessage[]) => void;
onProfileIncomplete?: () => void;
}>;
export async function runRectificationStructuredChoice(
deps: RectificationStructuredChoiceDeps,
action: RectificationStructuredChoiceAction,
optionId: ChoiceKey | "stop" | "skip_probe",
override?: RectificationStructuredChoiceOverride,
): Promise<void> {
const {
caseId,
sessionId,
busy,
readonly,
choiceCard,
currentQuestion,
keyCounter,
runAbort,
lastRunOutcome,
conversationAnchor,
actionIdForChoice,
setError,
setMessages,
setChoiceNonce,
setNextUserActionId,
setPending,
beginLiveRun,
loadCaseSnapshot,
send,
onCompleted,
onMessagesChange,
onProfileIncomplete,
} = deps;
const focusId = override?.focusId ?? choiceCard?.focus_id ?? (
action === STOP_ACTION && currentQuestion?.kind === "collect_spoken"
? currentQuestion.focus_id
: null
);
if (busy || readonly) return;
if (action !== STOP_ACTION && (!focusId || (!override && !choiceCard))) return;
if (focusId && !isPersistedFocusId(focusId)) {
setError("当前选择题已失效,请等待下一问。");
return;
}
const questionId = override?.questionId ?? choiceCard?.question_id ?? currentQuestion?.question_id ?? null;
const probeId = override?.probeId ?? choiceCard?.probe_id;
const expectedRevision = override?.caseRevision ?? choiceCard?.case_revision ?? 0;
const actionId = actionIdForChoice(focusId ?? caseId, optionId);
setError("");
keyCounter.current += 1;
const turnKey = keyCounter.current;
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer;
beginLiveRun(recordingLabel);
setMessages((current) => [
...markQuestionAnswered(current, focusId ?? "", optionId),
{
role: "assistant",
text: "",
renderKey: assistantRenderKey,
state: "thinking",
activityTrace: emptyActivityTrace(),
activity: {
phase: "evidence-validation",
label: recordingLabel,
startedAt: Date.now(),
},
},
]);
conversationAnchor.pinLatestTurn();
setPending(true);
// BUG-1046 (D2): a tap that did not land takes back its local answered
// mark and placeholder; the card key changes (answer_option → nonce), so
// the card remounts clean and one tappable card is left.
const rollBackFailedChoice = () => {
setMessages((current) => withdrawAnswerMark(
current.filter((message) => message.renderKey !== assistantRenderKey),
focusId ?? "",
optionId,
));
setChoiceNonce((current) => current + 1);
};
// The server may have moved on (stale_question, a dropped response after
// it applied): re-read the Case so the card reflects what it holds.
const reloadAfterFailedChoice = () => loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current,
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
const abortController = new AbortController();
runAbort.current = abortController;
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
headers: { "content-type": "application/json" },
signal: abortController.signal,
body: JSON.stringify({
caseId,
sessionId,
requestId: actionId,
action,
actionId,
...(focusId ? { focusId } : {}),
...(questionId ? { questionId } : {}),
probeId: probeId ?? null,
optionId: optionId === "stop" ? undefined : optionId,
expectedRevision,
origin: "choice_click",
clientActionId: actionId,
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
rollBackFailedChoice();
if (payload?.code === "profile_incomplete") {
onProfileIncomplete?.();
return;
}
setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY);
await reloadAfterFailedChoice();
return;
}
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
const payloadNextActionId = typeof payload?.next_user_action?.id === "string"
? payload.next_user_action.id.trim()
: "";
if (payloadNextActionId) setNextUserActionId(payloadNextActionId);
onCompleted?.();
await loadCaseSnapshot((turns) => {
if (willContinue) {
// D4 (BUG-1046, the same gap BUG-685 closed for the tie-break entry): a turn
// the server wrote while applying the tap joins the transcript,
// above the row the follow-up run continues on.
setMessages((current) => mergeTurnQuestions(
appendUnseenAssistantTurns(
current,
messagesFromTurns(turns as readonly PersistedTurn[]),
{ beforeRenderKey: assistantRenderKey },
),
turns,
));
return;
}
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
: "已记录你的选择。";
setMessages((current) => {
const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey);
const withHistory = appendUnseenAssistantTurns(
mergeTurnQuestions(withoutPlaceholder, turns),
messagesFromTurns(turns as readonly PersistedTurn[]),
);
if (withHistory.length > withoutPlaceholder.length) return withHistory;
return [
...withHistory,
{
role: "assistant" as const,
text: narration,
renderKey: assistantRenderKey,
state: "settled" as const,
activity: undefined,
},
];
});
});
if (willContinue) {
// 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).
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
} else {
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
: "已记录你的选择。";
onMessagesChange?.([
{ role: "assistant", text: narration },
]);
}
} catch (caught) {
if (isAbortError(caught)) {
lastRunOutcome.current = "stopped";
setMessages((current) => current.map((message) => (
message.renderKey !== assistantRenderKey ? message : {
...message,
state: "settled" as const,
failed: false,
stopped: true,
activity: undefined,
}
)));
return;
}
lastRunOutcome.current = "failed";
rollBackFailedChoice();
setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY);
await reloadAfterFailedChoice();
} finally {
if (runAbort.current === abortController) runAbort.current = null;
if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded";
setPending(false);
}
}