/** * What the rectification chat surface shows this render, derived from its * state: the latest settled answer, the live choice card, * the range delivery card, the read-only range, the question gap * (preparing / unavailable / persisted question / collect-waiting / * delivered / verified idle), where the persisted question hangs, and the * timeline bar. Moved out of RectificationAgenticChat unchanged * (TASK-rectification-code-split-20260926). Pure: the component applies the * returned `nextOfferLock` itself; no React hooks here. */ import { canShowRectificationReadonlyRange, canShowRectificationSelectionCards, } from "@/lib/rectification-candidate-result"; import { workingRectificationTime } from "@/lib/rectification-board-model"; import { RECTIFICATION_QUESTION_RETRY_LIMIT, rectificationConversationState, rectificationQuestionGapState, interviewCollectWaiting, interviewChoiceCardUnavailable, } from "@/lib/rectification-surface-state"; import { buildRectificationTimeline, type RectificationTimelineStage, } from "@/lib/rectification-timeline-scale"; import { postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy"; import { isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status"; import type { RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card"; import { interviewQuestionBlocksAdoptOffer, nextSelectionCardLock, questionIsAnswered, resolveSelectionCardMessageKey, turnQuestionFromCurrent, type SelectionCardLock, } from "@/lib/rectification-agentic/v9/turn-question"; import { placePersistedQuestion } from "@/lib/rectification-snapshot-messages"; import { isSettledAssistantMessage } from "@/lib/rectification-chat-messages"; import type { CandidateResult, CurrentQuestionModel } from "@/lib/rectification-chat-snapshot"; import type { RenderMessage } from "@/components/rectification-message-entry"; export type RectificationChatViewInput = Readonly<{ messages: RenderMessage[]; busy: boolean; readonly: boolean; shouldStartOpening: boolean; openingRequested: boolean; acceptingCandidateId: string | null; selectionOfferLock: SelectionCardLock | null; candidateResult: CandidateResult; currentQuestion: CurrentQuestionModel | null; questionSource: "focus" | "unavailable" | null; choiceCard: ChoiceCardModel | null; caseStatus: RectificationCaseStatus | null; caseSnapshotLoaded: boolean; searchWindow: readonly [string, string] | null; caseStage: RectificationTimelineStage | null; nextUserActionId: string | null; interviewStopReason: string | null; interviewSessionOutcome: string | null; questionRetryAttempts: number; savedTime: string | null; }>; export function deriveRectificationChatView(input: RectificationChatViewInput) { const { messages, busy, readonly, shouldStartOpening, openingRequested, acceptingCandidateId, selectionOfferLock, candidateResult, currentQuestion, questionSource, choiceCard, caseStatus, caseSnapshotLoaded, searchWindow, caseStage, nextUserActionId, interviewStopReason, interviewSessionOutcome, questionRetryAttempts, savedTime, } = input; const latestSettledAssistant = [...messages] .reverse() .find((message) => ( message.role === "assistant" && message.state === "settled" && !message.failed && Boolean(message.text) )); const latestLiveQuestion = [...messages].reverse().find((message) => ( message.role === "assistant" && message.question && !questionIsAnswered(message.question) ))?.question ?? null; const showLiveChoiceCard = Boolean( latestLiveQuestion && latestLiveQuestion.options?.length === 4 && currentQuestion?.focus_id === latestLiveQuestion.focus_id && choiceCard && choiceCard.focus_id === latestLiveQuestion.focus_id && (latestLiveQuestion.kind === "choice" || latestLiveQuestion.kind === "reverse_verify") && !busy && !readonly, ); const canOfferCards = canShowRectificationSelectionCards(candidateResult); // Cheap arithmetic over at most a dozen candidate minutes; recomputed with // the render rather than memoised, matching the rest of this component. const timelineView = buildRectificationTimeline({ searchWindow, credibleRange: candidateResult?.credibleRange ?? null, candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [], inferenceMarks: candidateResult?.inferenceMarks ?? [], stage: caseStage, // The same call the board's title-row clock makes, on the same result, so // the bar and `.rectification-board__clock` are one number by construction // rather than by two code paths that happen to agree today. workingTime: workingRectificationTime(candidateResult), workingAdopted: Boolean(candidateResult?.selectedTime), answeredProbeCount: candidateResult?.answeredProbeCount ?? null, datedEventCount: candidateResult?.rangeDelivery?.event_count ?? null, }); const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey; const liveSelectionCardKey = persistedOfferKey ?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined); // RectificationAgenticChat stores this lock during render (BUG-620); the // comment on why sits next to that setSelectionOfferLock call. const nextOfferLock = nextSelectionCardLock(selectionOfferLock, { resultId: candidateResult?.resultId, key: liveSelectionCardKey, }); const selectionCardMessageKey = resolveSelectionCardMessageKey({ persistedOfferKey, fallbackKey: latestSettledAssistant?.renderKey, locked: nextOfferLock, resultId: candidateResult?.resultId, selectedTime: candidateResult?.selectedTime, canOffer: canOfferCards, }); // Adopt already has in-card “正在采用…” / “已采用”; hiding the whole card // for `busy` is what made the three columns vanish on 「更像这个」. const keepSelectionCardsWhileBusy = Boolean( acceptingCandidateId || candidateResult?.selectedTime, ); const liveInterviewBlockingAdopt = Boolean( candidateResult && !candidateResult.selectedTime && messages.some((message) => ( interviewQuestionBlocksAdoptOffer(message.question, false) )), ); const showSelectionCards = Boolean( candidateResult && caseSnapshotLoaded && (!busy || keepSelectionCardsWhileBusy) && selectionCardMessageKey && (canOfferCards || Boolean(candidateResult.selectedTime)) ); const showReadonlyRange = Boolean( canShowRectificationReadonlyRange(candidateResult) && latestSettledAssistant && !showSelectionCards, ); const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken" ? currentQuestion.prompt : null; const resumableCase = caseStatus !== null && isResumableStatus(caseStatus); const deadChoice = interviewChoiceCardUnavailable({ questionKind: currentQuestion?.kind, hasChoiceCard: Boolean(choiceCard), questionId: currentQuestion?.question_id, }); const liveQuestionOnMessages = Boolean( latestSettledAssistant && latestSettledAssistant.question && currentQuestion && latestSettledAssistant.question.focus_id === currentQuestion.focus_id && !questionIsAnswered(latestSettledAssistant.question) && !deadChoice, ); // The gap between a settled turn and its next question has two visible // states: `preparing` (one live timeline row, timed refetches) and // `unavailable` (copy and a repair button) — never bare copy telling the // reader to wait for the server. Collect-waiting is the training-gate gap: // no next card, keep the case open, do not show 「没有拿到下一个问题」. const collectWaiting = interviewCollectWaiting({ stopReason: interviewStopReason, sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null, questionMissing: currentQuestion === null, offeringCards: showSelectionCards, }); // BUG-917: the same judgement the message entry makes, read off the latest // settled assistant message: a tap question nobody can answer any more. const unansweredDeadChoiceOnMessages = Boolean( latestSettledAssistant?.question && (latestSettledAssistant.question.kind === "choice" || latestSettledAssistant.question.kind === "reverse_verify") && !latestSettledAssistant.question.answer_option && (latestSettledAssistant.question.status === "active" || latestSettledAssistant.question.status === "superseded") && !liveQuestionOnMessages, ); const questionGap = rectificationQuestionGapState({ liveQuestionVisible: liveQuestionOnMessages, unansweredDeadChoice: unansweredDeadChoiceOnMessages, questionMissing: currentQuestion === null, questionLoadFailed: questionSource === "unavailable" || deadChoice, questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice, offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime && currentQuestion === null, nextUserActionId, collectWaiting: collectWaiting && !deadChoice, sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null, stopReason: interviewStopReason, deadChoice, questionId: currentQuestion?.question_id ?? null, hasChoiceCard: Boolean(choiceCard), busy, readonly, snapshotLoaded: caseSnapshotLoaded, resumableCase, retryAttempts: questionRetryAttempts, retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT, }); // BUG-1046 (D3): the persisted question is drawn once. With an assistant // message to carry it, it hangs there (BUG-678's intent) and other copies of // the same focus come off; the standalone block is only the fallback when // nothing can carry it. The gap state itself is unchanged. const persistedTurnQuestion = questionGap === "persisted_question" ? turnQuestionFromCurrent(currentQuestion, choiceCard) : null; const placedQuestion = persistedTurnQuestion ? placePersistedQuestion(messages, persistedTurnQuestion, isSettledAssistantMessage) : null; const placedByKey = placedQuestion && placedQuestion.messages !== messages ? new Map(placedQuestion.messages.map((message) => [message.renderKey, message])) : null; const showStandaloneQuestion = questionGap === "persisted_question" && (placedQuestion ? placedQuestion.standalone : true); const verifiedIdleCopy = questionGap === "verified_idle" ? postAdoptVerifyDoneCopy( savedTime, messages.some((message) => message.question?.kind === "reverse_verify"), ) : null; const conversationState = rectificationConversationState({ messageCount: messages.length, busy, readonly, shouldStartOpening, openingStarted: openingRequested, }); return { latestSettledAssistant, showLiveChoiceCard, timelineView, nextOfferLock, selectionCardMessageKey, liveInterviewBlockingAdopt, showSelectionCards, showReadonlyRange, collectSpokenPrompt, liveQuestionOnMessages, questionGap, placedByKey, showStandaloneQuestion, verifiedIdleCopy, conversationState, }; } export type RectificationChatView = ReturnType;