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
@@ -2,16 +2,28 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { homeSurface as page } from "./home-surface.ts";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
import {
abortWiringOf,
chatViewInput,
fixtureChoiceCard,
offerableCandidateResult,
RUN_FOCUS_ID,
settledAssistant,
} from "./rectification-chat-run-fixtures.ts";
import { deriveRectificationChatView } from "../src/lib/rectification-chat-view.ts";
import { completedReceiptFromPersisted } from "../src/lib/rectification-chat-messages.ts";
import { RectificationQuestionGapNotices } from "../src/components/rectification-question-gap-notices.tsx";
const component = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
"utf8",
);
const surfaceState = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
const messageEntry = readFileSync(
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
"utf8",
@@ -362,15 +374,29 @@ test("Agentic rectification follows the conversation tail only while the reader
});
test("rectification keeps receipts for the varga sentence and shows live tool progress", () => {
const activityHelper = chat.slice(
chat.indexOf("function completedReceiptFromPersisted"),
chat.indexOf("export function RectificationAgenticChat"),
);
assert.match(activityHelper, /receipt\.tools/);
assert.doesNotMatch(activityHelper, /receipt\.phases/);
assert.match(activityHelper, /filter\(isPublicRectificationTool\)/);
assert.match(activityHelper, /receipt\.methods/);
assert.match(activityHelper, /filter\(isPublicRectificationMethod\)/);
// 原值: 从 chat 源码切 `function completedReceiptFromPersisted` 到 `export function RectificationAgenticChat`,
// 匹配 receipt.tools / receipt.methods / filter(isPublicRectificationTool) / filter(isPublicRectificationMethod),且不含 receipt.phases。
// 新值: 直接调用 completedReceiptFromPersisted:旧形状回执只留公开工具(去重)与公开方法,phases 不进步骤;
// tool_activities 形状只收 completed / failed 的公开工具与公开方法。
// 原因: TASK-rectification-code-split-20260926 把该函数原样搬到 rectification-chat-messages.ts;对返回值断言强于源码 token。
const legacyReceipt = completedReceiptFromPersisted({
status: "completed",
phases: ["case.loaded", "run.completed"],
tools: ["rectification-read-case", "internal-debug-tool", "rectification-read-case"],
methods: ["d1-rashi", "not-a-public-method"],
});
assert.deepEqual(legacyReceipt, { steps: ["rectification-read-case"], methods: ["d1-rashi"] });
const activityReceipt = completedReceiptFromPersisted({
status: "completed",
phases: ["case.loaded"],
tools: ["rectification-set-focus"],
tool_activities: [
{ tool: "rectification-read-case", status: "completed", methods: ["d1-rashi", "not-a-public-method"] },
{ tool: "internal-debug-tool", status: "completed" },
{ tool: "rectification-set-focus", status: "started" },
],
});
assert.deepEqual(activityReceipt, { steps: ["rectification-read-case"], methods: ["d1-rashi"] });
// 原值: 上述 token 都在 rectification-agentic-chat.tsx 的 messages.map 内。
// 新值: RenderMessage / 时间轴 / varga 句随行组件抽出。
// 原因: BUG-725 已结算行记忆化,派生不得留在容器每帧计算。
@@ -643,19 +669,25 @@ test("rectification composer can stop a live agent run", () => {
assert.match(messageEntry, /showActivity=\{displayedMessage\.state !== "settled"\}/);
});
test("aborting a live run marks the bubble stopped without an alert", () => {
test("aborting a live run marks the bubble stopped without an alert", async () => {
assert.match(chat, /failed: false,\s*stopped: true/);
// 原值: 停止提示 prop 写在容器 map 的 ChatMessageRow 上。
// 新值: 同一表达式在行组件内。
// 原因: BUG-725。
assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.doesNotMatch(chat, /role="alert"[\s\S]{0,80}RECTIFICATION_STOPPED_NOTICE/);
const choiceFetch = chat.slice(chat.indexOf("const submitStructuredChoice"), chat.indexOf("const acceptCandidate"));
assert.match(choiceFetch, /runAbort\.current = abortController/);
assert.match(choiceFetch, /signal: abortController\.signal/);
const adoptFetch = chat.slice(chat.indexOf("const acceptCandidate"), chat.indexOf("async function copyMessage"));
assert.match(adoptFetch, /runAbort\.current = abortController/);
assert.match(adoptFetch, /signal: abortController\.signal/);
// 原值: 从 chat 源码切 submitStructuredChoice / acceptCandidate 两段,各匹配 `runAbort.current = abortController` 与 `signal: abortController.signal`。
// 新值: 调用 runRectificationStructuredChoice / runRectificationCandidateAccept(假 fetch):请求带的 signal
// 就是 runAbort 里那个控制器的(停止按钮能掐断它),结束后 runAbort 清空。
// 原因: TASK-rectification-code-split-20260926 两段函数体原样搬进参数函数;行为断言覆盖接线本身。
const choiceWiring = await abortWiringOf("choice");
assert.equal(choiceWiring.url, "/api/rectification/agent");
assert.equal(choiceWiring.signalMatchesRunAbort, true);
assert.equal(choiceWiring.runAbortCleared, true);
const adoptWiring = await abortWiringOf("accept");
assert.match(adoptWiring.url, /\/api\/rectification\/cases\/[^/]+\/candidates\/accept$/);
assert.equal(adoptWiring.signalMatchesRunAbort, true);
assert.equal(adoptWiring.runAbortCleared, true);
});
test("conversation and house board reuse the quiet overlay scrollbar", () => {
@@ -696,14 +728,22 @@ test("time-selection cards use server adoption state and stay mutually exclusive
assert.match(chat, /keepSelectionCardsWhileBusy/);
assert.match(chat, /\.reverse\(\)\.find\(\(message\) => message\.candidateOffer\)/);
assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*latestLiveQuestion[\s\S]*options\?\.length === 4[\s\S]*!busy/);
assert.match(
chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const collectSpokenPrompt")),
/!busy \|\| keepSelectionCardsWhileBusy/,
);
assert.doesNotMatch(
chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const collectSpokenPrompt")),
/offeredSelectionOnce/,
// 原值: 从 chat 源码切 `const showSelectionCards` 到 `const collectSpokenPrompt`:含
// `!busy || keepSelectionCardsWhileBusy`,不含 `offeredSelectionOnce`。
// 新值: 调用 deriveRectificationChatView:busy 收卡、busy 但正在采用留卡;同一输入先后派生两次结果相同,
// 出过一次卡不会留下隐藏记忆。
// 原因: TASK-rectification-code-split-20260926 派生逻辑原样搬进纯函数 rectification-chat-view.ts。
const offerable = offerableCandidateResult();
const offerMessages = [settledAssistant("offer-1")];
const offerIdle = chatViewInput({ messages: offerMessages, candidateResult: offerable });
assert.equal(deriveRectificationChatView(offerIdle).showSelectionCards, true);
assert.equal(deriveRectificationChatView({ ...offerIdle, busy: true }).showSelectionCards, false);
assert.equal(
deriveRectificationChatView({ ...offerIdle, busy: true, acceptingCandidateId: offerable.candidates[0].candidateId }).showSelectionCards,
true,
);
// 出过卡、采用中之后再来一轮 busy:仍收卡,没有 offeredSelectionOnce 式的记忆。
assert.equal(deriveRectificationChatView({ ...offerIdle, busy: true }).showSelectionCards, false);
assert.match(chat, /!candidateResult\?\.selectedTime \? latestSettledAssistant\?\.renderKey/);
// 原值: showSelectionCards && message.renderKey === selectionCardMessageKey
// 新值: 还要有 candidateResult 才渲染行选卡
@@ -716,10 +756,32 @@ test("time-selection cards use server adoption state and stay mutually exclusive
assert.match(messageEntry, /variant="embedded"/);
assert.match(messageEntry, /choiceNonce/);
assert.match(messageEntry, /question\.answer_option/);
assert.match(messageLoop, /<RectificationChoiceCard/);
assert.match(messageLoop, /variant="embedded"/);
assert.match(messageLoop, /choiceNonce/);
// 原值: messageLoop(容器 map 到 savedTime 之间)含 persisted 题卡 <RectificationChoiceCard / variant="embedded" / choiceNonce。
// 新值: messageLoop 挂 <RectificationQuestionGapNotices 并传 choiceNonce;真渲染该块:persisted 选择题出嵌入卡(is-embedded、四个选项)。
// 原因: TASK-rectification-code-split-20260926 缺题块(含 persisted 题卡)抽成组件,位置不变。
assert.match(messageLoop, /<RectificationQuestionGapNotices/);
assert.match(messageLoop, /choiceNonce=\{choiceNonce\}/);
const persistedCard = fixtureChoiceCard();
const persistedHtml = renderToStaticMarkup(createElement(RectificationQuestionGapNotices, {
view: { showStandaloneQuestion: true, questionGap: "persisted_question", showSelectionCards: false, verifiedIdleCopy: null },
currentQuestion: { kind: "choice", prompt: persistedCard.prompt, focus_id: RUN_FOCUS_ID, question_id: persistedCard.question_id },
choiceCard: persistedCard,
choiceNonce: 0,
busy: false,
readonly: false,
snapshotFailure: null,
questionRepairAttempts: 0,
questionRepairing: false,
submitChoice() {},
submitStop() {},
async refetchQuestion() {},
async repairQuestion() {},
}));
assert.match(persistedHtml, /data-testid="persisted-question"/);
assert.match(persistedHtml, /class="rectification-choice-card is-embedded"/);
assert.equal(persistedHtml.match(/class="birth-time-choice-option is-primary"/g)?.length, 4);
assert.doesNotMatch(messageLoop, /rectification-question-slot/);
assert.doesNotMatch(persistedHtml, /rectification-question-slot/);
assert.doesNotMatch(messageEntry, /rectification-question-slot/);
assert.doesNotMatch(choiceCardComponent, /setSelectedKey\(""\)/);
assert.match(choiceCardComponent, /selectedKey/);