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
212 lines
9.4 KiB
TypeScript
212 lines
9.4 KiB
TypeScript
"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<string | null>(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null);
|
|
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null);
|
|
const [candidateResult, setCandidateResult] = useState<CandidateResult>(() => caseSnapshotState(initialSnapshot)?.candidate ?? null);
|
|
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(() => caseSnapshotState(initialSnapshot)?.choice ?? null);
|
|
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
|
|
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
|
|
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
|
|
const [searchWindow, setSearchWindow] = useState<readonly [string, string] | null>(() => caseSnapshotState(initialSnapshot)?.searchWindow ?? null);
|
|
const [caseStage, setCaseStage] = useState<RectificationTimelineStage | null>(() => caseSnapshotState(initialSnapshot)?.stage ?? null);
|
|
const [nextUserActionId, setNextUserActionId] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null);
|
|
const [interviewStopReason, setInterviewStopReason] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.interviewStopReason ?? null);
|
|
const [interviewSessionOutcome, setInterviewSessionOutcome] = useState<string | null>(() => 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<RectificationSnapshotFailure | null>(null);
|
|
const snapshotAbort = useRef<AbortController | null>(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<typeof useRectificationCaseSnapshot>;
|