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
184 lines
6.5 KiB
TypeScript
184 lines
6.5 KiB
TypeScript
/**
|
|
* Adopting a candidate minute from the range delivery card: one live row for
|
|
* the accept POST and the agent turn that follows on it, the working house
|
|
* table recast from the adopted minute, and the stop / fail branches. Moved
|
|
* out of RectificationAgenticChat's `acceptCandidate` unchanged
|
|
* (TASK-rectification-code-split-20260926). A parameter function: the
|
|
* component hands in its state, setters and refs; no React hooks here.
|
|
*/
|
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
import { emptyActivityTrace } from "@/lib/agent-activity-trace";
|
|
import {
|
|
natalRecastMeaning,
|
|
workingRectificationHouseTable,
|
|
} from "@/lib/rectification-candidate-result";
|
|
import {
|
|
rectificationAdoptingLabel,
|
|
isAbortError,
|
|
} from "@/lib/rectification-surface-state";
|
|
import type { CandidateResult } from "@/lib/rectification-chat-snapshot";
|
|
import type {
|
|
RectificationRunOutcome,
|
|
RectificationSnapshotLoader,
|
|
RectificationTurnAction,
|
|
RectificationTurnContinuation,
|
|
} from "@/lib/rectification-chat-turn-run";
|
|
import type { RenderMessage } from "@/components/rectification-message-entry";
|
|
|
|
export type RectificationCandidateAcceptDeps = Readonly<{
|
|
caseId: string;
|
|
sessionId: string;
|
|
busy: boolean;
|
|
readonly: boolean;
|
|
candidateResult: CandidateResult;
|
|
acceptingCandidateId: string | null;
|
|
keyCounter: MutableRefObject<number>;
|
|
runAbort: MutableRefObject<AbortController | null>;
|
|
lastRunOutcome: MutableRefObject<RectificationRunOutcome>;
|
|
conversationAnchor: Readonly<{ pinLatestTurn: () => void }>;
|
|
setError: Dispatch<SetStateAction<string>>;
|
|
setMessages: Dispatch<SetStateAction<RenderMessage[]>>;
|
|
setAcceptingCandidateId: Dispatch<SetStateAction<string | null>>;
|
|
setCandidateResult: Dispatch<SetStateAction<CandidateResult>>;
|
|
setSavedTime: Dispatch<SetStateAction<string | null>>;
|
|
setSavedStatus: Dispatch<SetStateAction<"accepted" | "confirmed" | null>>;
|
|
setPending: (value: boolean) => void;
|
|
beginLiveRun: (baseLabel: string) => void;
|
|
loadCaseSnapshot: RectificationSnapshotLoader;
|
|
send: (
|
|
action: RectificationTurnAction,
|
|
messageText: string,
|
|
continuation?: RectificationTurnContinuation,
|
|
) => Promise<void>;
|
|
onCompleted?: () => void;
|
|
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
|
|
}>;
|
|
|
|
export async function runRectificationCandidateAccept(
|
|
deps: RectificationCandidateAcceptDeps,
|
|
candidateId: string,
|
|
): Promise<void> {
|
|
const {
|
|
caseId,
|
|
sessionId,
|
|
busy,
|
|
readonly,
|
|
candidateResult,
|
|
acceptingCandidateId,
|
|
keyCounter,
|
|
runAbort,
|
|
lastRunOutcome,
|
|
conversationAnchor,
|
|
setError,
|
|
setMessages,
|
|
setAcceptingCandidateId,
|
|
setCandidateResult,
|
|
setSavedTime,
|
|
setSavedStatus,
|
|
setPending,
|
|
beginLiveRun,
|
|
loadCaseSnapshot,
|
|
send,
|
|
onCompleted,
|
|
onSaved,
|
|
} = deps;
|
|
if (!candidateResult || acceptingCandidateId || busy || readonly) return;
|
|
setError("");
|
|
setAcceptingCandidateId(candidateId);
|
|
setPending(true);
|
|
// Adoption is a server round trip followed by an agent turn; the reader sees
|
|
// one live row for the whole of it instead of a greyed button.
|
|
const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? "";
|
|
const adoptingLabel = rectificationAdoptingLabel(candidateTime);
|
|
keyCounter.current += 1;
|
|
const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`;
|
|
beginLiveRun(adoptingLabel);
|
|
setMessages((current) => [
|
|
...current,
|
|
{
|
|
role: "assistant",
|
|
text: "",
|
|
renderKey: assistantRenderKey,
|
|
state: "thinking",
|
|
activityTrace: emptyActivityTrace(),
|
|
activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() },
|
|
},
|
|
]);
|
|
conversationAnchor.pinLatestTurn();
|
|
const abortController = new AbortController();
|
|
runAbort.current = abortController;
|
|
try {
|
|
const response = await fetch(
|
|
`/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
signal: abortController.signal,
|
|
body: JSON.stringify({
|
|
sessionId,
|
|
resultId: candidateResult.resultId,
|
|
candidateId,
|
|
requestId: globalThis.crypto.randomUUID(),
|
|
mode: "range",
|
|
}),
|
|
},
|
|
);
|
|
const payload = await response.json().catch(() => null);
|
|
if (!response.ok || payload?.ok !== true || payload?.status !== "accepted") {
|
|
throw new Error(payload?.error || payload?.message || "暂时无法采用该候选时间");
|
|
}
|
|
setCandidateResult((current) => {
|
|
if (!current) return current;
|
|
const selectedTime = typeof payload.saved_time === "string" ? payload.saved_time.slice(0, 5) : current.selectedTime;
|
|
const next = {
|
|
...current,
|
|
selectedTime,
|
|
selectionKind: "user_accepted" as const,
|
|
};
|
|
const houseTable = workingRectificationHouseTable(next);
|
|
const recastMeaning = natalRecastMeaning(houseTable);
|
|
return {
|
|
...next,
|
|
houseTable: houseTable ?? current.houseTable,
|
|
natalRecast: houseTable && recastMeaning
|
|
? {
|
|
time: houseTable.time,
|
|
lagna: houseTable.lagna,
|
|
user_meaning: recastMeaning,
|
|
unique_minute_claim: false as const,
|
|
confirmation_allowed: false as const,
|
|
}
|
|
: current.natalRecast,
|
|
};
|
|
});
|
|
setSavedTime(payload.saved_time);
|
|
setSavedStatus("accepted");
|
|
onSaved?.(payload.saved_time, "accepted");
|
|
onCompleted?.();
|
|
await loadCaseSnapshot();
|
|
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel });
|
|
} catch (caught) {
|
|
if (isAbortError(caught)) {
|
|
lastRunOutcome.current = "stopped";
|
|
setMessages((current) => current.map((message) => (
|
|
message.renderKey !== assistantRenderKey ? message : {
|
|
...message,
|
|
state: "settled" as const,
|
|
failed: false,
|
|
stopped: true,
|
|
activity: undefined,
|
|
}
|
|
)));
|
|
return;
|
|
}
|
|
lastRunOutcome.current = "failed";
|
|
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
|
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
|
|
} finally {
|
|
if (runAbort.current === abortController) runAbort.current = null;
|
|
if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded";
|
|
setAcceptingCandidateId(null);
|
|
setPending(false);
|
|
}
|
|
}
|