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
95 lines
4.4 KiB
TypeScript
95 lines
4.4 KiB
TypeScript
/**
|
|
* Pure reads of the Case snapshot payload for the rectification chat surface:
|
|
* the current question, its source, and the initial state that arrives with
|
|
* the reveal. Moved out of rectification-agentic-chat.tsx unchanged
|
|
* (TASK-rectification-code-split-20260926); no React hooks here.
|
|
*/
|
|
import {
|
|
parseRectificationCandidateResult,
|
|
type RectificationCandidateResult,
|
|
} from "@/lib/rectification-candidate-result";
|
|
import {
|
|
searchWindowFromSnapshot,
|
|
caseStageFromSnapshot,
|
|
interviewStopReasonFromSnapshot,
|
|
interviewSessionOutcomeFromSnapshot,
|
|
birthTimeSourceFromSnapshot,
|
|
type RectificationCaseSnapshotPayload,
|
|
} 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";
|
|
|
|
export type CandidateResult = RectificationCandidateResult | null;
|
|
export type CurrentQuestionModel = Readonly<{
|
|
kind: "choice" | "collect_spoken";
|
|
prompt: string | null;
|
|
focus_id: string | null;
|
|
question_id: string | null;
|
|
}>;
|
|
|
|
export function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const question = value as { kind?: unknown; prompt?: unknown; focus_id?: unknown; question_id?: unknown };
|
|
if (question.kind !== "choice" && question.kind !== "collect_spoken" && question.kind !== "reverse_verify") {
|
|
return null;
|
|
}
|
|
return {
|
|
kind: question.kind === "collect_spoken" ? "collect_spoken" : "choice",
|
|
prompt: typeof question.prompt === "string" && question.prompt.trim()
|
|
? question.prompt.trim()
|
|
: null,
|
|
focus_id: typeof question.focus_id === "string" ? question.focus_id : null,
|
|
question_id: typeof question.question_id === "string" ? question.question_id : null,
|
|
};
|
|
}
|
|
|
|
export function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | null {
|
|
if (value === "unavailable") return "unavailable";
|
|
if (value === "focus") return "focus";
|
|
return null;
|
|
}
|
|
|
|
export type CaseSnapshotState = Readonly<{
|
|
candidate: CandidateResult;
|
|
question: CurrentQuestionModel | null;
|
|
questionSource: "focus" | "unavailable" | null;
|
|
choice: ChoiceCardModel | null;
|
|
caseStatus: RectificationCaseStatus | null;
|
|
savedTime: string | null;
|
|
savedStatus: "accepted" | "confirmed" | null;
|
|
nextUserActionId: string | null;
|
|
searchWindow: readonly [string, string] | null;
|
|
stage: RectificationTimelineStage | null;
|
|
interviewStopReason: string | null;
|
|
interviewSessionOutcome: string | null;
|
|
birthTimeSource: ReturnType<typeof birthTimeSourceFromSnapshot>;
|
|
}>;
|
|
|
|
export function nextUserActionIdFromSnapshot(payload: RectificationCaseSnapshotPayload | null): string | null {
|
|
const id = payload?.next_user_action?.id;
|
|
return typeof id === "string" && id.trim() ? id.trim() : null;
|
|
}
|
|
|
|
/** The snapshot that arrived with the reveal, as initial state; nothing is fetched on mount. */
|
|
export function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): CaseSnapshotState | null {
|
|
if (!payload) return null;
|
|
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
|
|
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
|
|
return {
|
|
candidate: parseRectificationCandidateResult(payload.latest_result),
|
|
question: currentQuestionFromSnapshot(payload.current_question),
|
|
questionSource: questionSourceFromSnapshot(payload.question_source),
|
|
choice: parseRectificationChoiceCard(payload.choice_card),
|
|
caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null,
|
|
savedTime: confirmedTime ?? acceptedTime,
|
|
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
|
|
nextUserActionId: nextUserActionIdFromSnapshot(payload),
|
|
searchWindow: searchWindowFromSnapshot(payload.case?.candidate_range),
|
|
stage: caseStageFromSnapshot(payload.case?.stage),
|
|
interviewStopReason: interviewStopReasonFromSnapshot(payload),
|
|
interviewSessionOutcome: interviewSessionOutcomeFromSnapshot(payload),
|
|
birthTimeSource: birthTimeSourceFromSnapshot(payload),
|
|
};
|
|
}
|