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
This commit is contained in:
Jesse_Chen
2026-09-26 19:23:52 +08:00
co-authored by Claude Opus 5.5
parent 12cbe6f868
commit 3a66c39f48
61 changed files with 3402 additions and 1731 deletions
@@ -0,0 +1,53 @@
"use client";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import {
diffRectificationBoard,
RECTIFICATION_BOARD_SPLIT_MIN_PX,
} from "@/lib/rectification-board-model";
import type { CandidateResult } from "@/lib/rectification-chat-snapshot";
/**
* Board layout of the rectification surface: compact (drawer) vs split view,
* the drawer's open state, and the change highlight between two candidate
* results. Moved out of RectificationAgenticChat unchanged
* (TASK-rectification-code-split-20260926).
*/
export function useRectificationBoardLayout(candidateResult: CandidateResult) {
const previousBoardResult = useRef<CandidateResult>(null);
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
const boardId = useId();
const boardTitleId = useId();
useLayoutEffect(() => {
const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`);
const update = () => {
const nextCompact = query.matches;
setCompactBoard(nextCompact);
if (!nextCompact) setBoardOpen(false);
};
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
useEffect(() => {
setBoardDiff(diffRectificationBoard(previousBoardResult.current, candidateResult));
previousBoardResult.current = candidateResult;
}, [candidateResult]);
const closeBoard = useCallback(() => setBoardOpen(false), []);
const toggleBoard = useCallback(() => setBoardOpen((current) => !current), []);
return {
compactBoard,
boardOpen,
boardDiff,
boardId,
boardTitleId,
closeBoard,
toggleBoard,
};
}
@@ -0,0 +1,211 @@
"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>;
@@ -0,0 +1,66 @@
"use client";
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import { rectificationLiveProgressLabel } from "@/lib/rectification-activity-labels";
import type { RenderMessage } from "@/components/rectification-message-entry";
/**
* The live working row's label clock: when the run and the current step
* started, which tool is running, and the 4-second tick that refreshes the
* elapsed-time label while a turn is busy. Moved out of
* RectificationAgenticChat unchanged (TASK-rectification-code-split-20260926).
*/
export function useRectificationLiveActivity({
busy,
setMessages,
}: Readonly<{
busy: boolean;
setMessages: Dispatch<SetStateAction<RenderMessage[]>>;
}>) {
const runStartedAtRef = useRef(0);
const stepStartedAtRef = useRef(0);
const liveToolRef = useRef<string | null>(null);
const liveBaseLabelRef = useRef("正在处理…");
const beginLiveRun = useCallback((baseLabel: string) => {
const now = Date.now();
runStartedAtRef.current = now;
stepStartedAtRef.current = now;
liveToolRef.current = null;
liveBaseLabelRef.current = baseLabel;
}, []);
const rememberLiveActivity = useCallback((baseLabel: string, tool: string | null = liveToolRef.current) => {
if (liveBaseLabelRef.current !== baseLabel) {
liveBaseLabelRef.current = baseLabel;
stepStartedAtRef.current = Date.now();
}
liveToolRef.current = tool;
return rectificationLiveProgressLabel({
tool,
baseLabel,
stepStartedAt: stepStartedAtRef.current,
runStartedAt: runStartedAtRef.current,
});
}, []);
useEffect(() => {
if (!busy) return;
const id = window.setInterval(() => {
const label = rectificationLiveProgressLabel({
tool: liveToolRef.current,
baseLabel: liveBaseLabelRef.current,
stepStartedAt: stepStartedAtRef.current,
runStartedAt: runStartedAtRef.current,
});
setMessages((current) => current.map((message) => {
if (message.state !== "thinking" && message.state !== "streaming") return message;
if (!message.activity || message.activity.label === label) return message;
return { ...message, activity: { ...message.activity, label } };
}));
}, 4000);
return () => window.clearInterval(id);
}, [busy]);
return { beginLiveRun, rememberLiveActivity };
}