"use client"; import { startTransition, useCallback, useLayoutEffect, useRef, useState } from "react"; import { parseRectificationCandidateResult, } from "@/lib/rectification-candidate-result"; import { isAbortError, searchWindowFromSnapshot, caseStageFromSnapshot, interviewStopReasonFromSnapshot, interviewSessionOutcomeFromSnapshot, birthTimeSourceFromSnapshot, type RectificationCaseSnapshotPayload, type RectificationSnapshotFailure, } from "@/lib/rectification-surface-state"; import type { RectificationTimelineStage } from "@/lib/rectification-timeline-scale"; import { isRectificationCaseStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status"; import { parseRectificationChoiceCard, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card"; import { snapshotTurns } from "@/lib/rectification-chat-messages"; import { caseSnapshotState, currentQuestionFromSnapshot, questionSourceFromSnapshot, type CandidateResult, type CurrentQuestionModel, } from "@/lib/rectification-chat-snapshot"; /** * Case snapshot sync for the rectification chat surface: the state that the * persisted Candidate Snapshot API owns (candidate, current question, choice * card, saved time, timeline window / stage, interview outcome), the * question-recovery counters that reset when a question arrives, and the one * snapshot read in flight. Moved out of RectificationAgenticChat unchanged * (TASK-rectification-code-split-20260926). */ export function useRectificationCaseSnapshot({ caseId, sessionId, initialSnapshot, }: Readonly<{ caseId: string; sessionId: string; initialSnapshot: RectificationCaseSnapshotPayload | null; }>) { const [resultNotice, setResultNotice] = useState(initialSnapshot?.case?.result_notice ?? null); const [canRecompare, setCanRecompare] = useState(initialSnapshot?.case?.result_identity?.can_recompare === true); const [savedTime, setSavedTime] = useState(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null); const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null); const [candidateResult, setCandidateResult] = useState(() => caseSnapshotState(initialSnapshot)?.candidate ?? null); const [choiceCard, setChoiceCard] = useState(() => caseSnapshotState(initialSnapshot)?.choice ?? null); const [currentQuestion, setCurrentQuestion] = useState(() => caseSnapshotState(initialSnapshot)?.question ?? null); const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null); const [caseStatus, setCaseStatus] = useState(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null); const [searchWindow, setSearchWindow] = useState(() => caseSnapshotState(initialSnapshot)?.searchWindow ?? null); const [caseStage, setCaseStage] = useState(() => caseSnapshotState(initialSnapshot)?.stage ?? null); const [nextUserActionId, setNextUserActionId] = useState(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null); const [interviewStopReason, setInterviewStopReason] = useState(() => caseSnapshotState(initialSnapshot)?.interviewStopReason ?? null); const [interviewSessionOutcome, setInterviewSessionOutcome] = useState(() => caseSnapshotState(initialSnapshot)?.interviewSessionOutcome ?? null); const [birthTimeSource, setBirthTimeSource] = useState(() => caseSnapshotState(initialSnapshot)?.birthTimeSource ?? birthTimeSourceFromSnapshot(initialSnapshot)); const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null); const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0); const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0); const [questionRepairing, setQuestionRepairing] = useState(false); const [snapshotFailure, setSnapshotFailure] = useState(null); const snapshotAbort = useRef(null); const currentQuestionRef = useRef(currentQuestion); useLayoutEffect(() => { currentQuestionRef.current = currentQuestion; }, [currentQuestion]); // Candidate snapshot comes from the persisted Candidate Snapshot API, never // from parsing agent text or hidden sentinels. const applyCaseSnapshot = useCallback((payload: RectificationCaseSnapshotPayload | { latest_result?: unknown; current_question?: unknown; choice_card?: unknown; step_state?: unknown; turns?: unknown; question_source?: unknown; next_user_action?: { id?: unknown }; interview?: { stop_reason?: unknown; session_outcome?: unknown }; case?: { status?: unknown; accepted_time?: unknown; confirmed_time?: unknown; candidate_range?: unknown; stage?: unknown; }; } | null) => { if (!payload) return; const snapshot = payload as RectificationCaseSnapshotPayload; setResultNotice(snapshot.case?.result_notice ?? null); setCanRecompare(snapshot.case?.result_identity?.can_recompare === true); const nextCandidate = parseRectificationCandidateResult(payload.latest_result); const nextQuestion = currentQuestionFromSnapshot(payload.current_question); const nextChoice = parseRectificationChoiceCard(payload.choice_card); const nextCaseStatus = isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null; const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null; const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null; const nextActionId = typeof payload.next_user_action?.id === "string" ? payload.next_user_action.id.trim() : ""; // Only overwrite when the payload actually carries them: a snapshot without // a window should leave the timeline showing the last known one rather than // dropping to the skeleton mid-session. const nextWindow = searchWindowFromSnapshot(payload.case?.candidate_range); if (nextWindow) setSearchWindow(nextWindow); const nextStage = caseStageFromSnapshot(payload.case?.stage); if (nextStage) setCaseStage(nextStage); setCandidateResult(nextCandidate); setCurrentQuestion(nextQuestion); setQuestionSource(questionSourceFromSnapshot(payload.question_source)); setChoiceCard(nextChoice); setCaseStatus(nextCaseStatus); setNextUserActionId(nextActionId || null); setInterviewStopReason(interviewStopReasonFromSnapshot(snapshot)); setInterviewSessionOutcome(interviewSessionOutcomeFromSnapshot(snapshot)); setBirthTimeSource(birthTimeSourceFromSnapshot(snapshot)); setCaseSnapshotLoaded(true); if (nextQuestion || nextChoice || acceptedTime || confirmedTime) { setQuestionRepairAttempts(0); } if (confirmedTime) { setSavedTime(confirmedTime); setSavedStatus("confirmed"); } else if (acceptedTime) { setSavedTime(acceptedTime); setSavedStatus("accepted"); } }, []); const loadCaseSnapshot = useCallback(async ( mergeMessages?: (turns: readonly unknown[]) => void, ): Promise<{ question: CurrentQuestionModel | null; turns: readonly unknown[]; } | null | undefined> => { snapshotAbort.current?.abort(); const controller = new AbortController(); snapshotAbort.current = controller; try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, { cache: "no-store", signal: controller.signal }, ); if (controller.signal.aborted) return undefined; if (!response.ok) { setSnapshotFailure({ status: response.status }); return undefined; } const payload = await response.json().catch(() => null); if (controller.signal.aborted) return undefined; const turns = snapshotTurns(payload); startTransition(() => { setSnapshotFailure(null); applyCaseSnapshot(payload); mergeMessages?.(turns); }); return { question: currentQuestionFromSnapshot(payload?.current_question), turns, }; } catch (error) { if (isAbortError(error) || controller.signal.aborted) return undefined; setSnapshotFailure({ status: "network" }); return undefined; } finally { if (snapshotAbort.current === controller) snapshotAbort.current = null; } }, [applyCaseSnapshot, caseId, sessionId]); return { resultNotice, canRecompare, savedTime, setSavedTime, savedStatus, setSavedStatus, candidateResult, setCandidateResult, choiceCard, currentQuestion, questionSource, caseStatus, searchWindow, caseStage, nextUserActionId, setNextUserActionId, interviewStopReason, interviewSessionOutcome, birthTimeSource, caseSnapshotLoaded, questionRetryAttempts, setQuestionRetryAttempts, questionRepairAttempts, setQuestionRepairAttempts, questionRepairing, setQuestionRepairing, snapshotFailure, snapshotAbort, currentQuestionRef, applyCaseSnapshot, loadCaseSnapshot, }; } export type RectificationCaseSnapshotSync = ReturnType;