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
160 lines
5.8 KiB
TypeScript
160 lines
5.8 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { createElement, createRef } from "react";
|
|
import { renderToString } from "react-dom/server";
|
|
import test from "node:test";
|
|
|
|
import type { ChatMessageFeedback } from "../src/components/chat-message-actions.tsx";
|
|
import {
|
|
RectificationMessageEntry,
|
|
UnsplitRectificationMessageList,
|
|
type RectificationMessageActions,
|
|
type RectificationMessageEntryProps,
|
|
type RenderMessage,
|
|
} from "../src/components/rectification-message-entry.tsx";
|
|
import {
|
|
disableHomeStreamingRenderProbe,
|
|
enableHomeStreamingRenderProbe,
|
|
homeStreamingRenderProbeSnapshot,
|
|
resetHomeStreamingRenderProbe,
|
|
} from "../src/lib/home-streaming-render-probe.ts";
|
|
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
|
|
|
|
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
|
|
const chatSource = rectificationChatSurface;
|
|
const entrySource = readFileSync(
|
|
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const rowSource = readFileSync(
|
|
new URL("../src/components/chat-message-row.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
|
|
function settled(role: "user" | "assistant", text: string, key: string): RenderMessage {
|
|
return {
|
|
role,
|
|
text,
|
|
renderKey: key,
|
|
state: "settled",
|
|
};
|
|
}
|
|
|
|
function liveAssistant(text: string): RenderMessage {
|
|
return {
|
|
role: "assistant",
|
|
text,
|
|
renderKey: "live-assistant",
|
|
state: "streaming",
|
|
};
|
|
}
|
|
|
|
function emptyActions(): RectificationMessageEntryProps["actionsRef"] {
|
|
const actionsRef = createRef<RectificationMessageActions>() as RectificationMessageEntryProps["actionsRef"];
|
|
actionsRef.current = {
|
|
submitChoice() {},
|
|
submitStop() {},
|
|
copyMessage() {},
|
|
regenerateMessage() {},
|
|
onFeedback() {},
|
|
};
|
|
return actionsRef;
|
|
}
|
|
|
|
function sharedProps(): Omit<
|
|
RectificationMessageEntryProps,
|
|
"message" | "regenerating" | "canRegenerate" | "copied" | "feedback"
|
|
> {
|
|
return {
|
|
busy: true,
|
|
readonly: false,
|
|
currentQuestionFocusId: null,
|
|
interactive: false,
|
|
liveChoiceCard: null,
|
|
choiceNonce: 0,
|
|
savedTime: null,
|
|
actionsRef: emptyActions(),
|
|
};
|
|
}
|
|
|
|
function entryProps(
|
|
message: RenderMessage,
|
|
extras: Partial<Pick<RectificationMessageEntryProps, "regenerating" | "canRegenerate" | "copied" | "feedback">> = {},
|
|
): RectificationMessageEntryProps {
|
|
return {
|
|
...sharedProps(),
|
|
message,
|
|
regenerating: extras.regenerating ?? false,
|
|
canRegenerate: extras.canRegenerate ?? false,
|
|
copied: extras.copied ?? false,
|
|
feedback: extras.feedback,
|
|
};
|
|
}
|
|
|
|
test("the chat container no longer derives per-row timeline, varga, or choice cards", () => {
|
|
assert.doesNotMatch(chatSource, /rectificationTimelineRows\s*\(/);
|
|
assert.doesNotMatch(chatSource, /vargaSentenceFromMethods\s*\(/);
|
|
assert.doesNotMatch(chatSource, /choiceCardFromQuestion\s*\(/);
|
|
assert.match(entrySource, /rectificationTimelineRows\s*\(/);
|
|
assert.match(entrySource, /vargaSentenceFromMethods\s*\(/);
|
|
assert.match(entrySource, /choiceCardFromQuestion\s*\(/);
|
|
});
|
|
|
|
test("the extracted row is memoised and takes no function props except actionsRef", () => {
|
|
assert.match(entrySource, /export const RectificationMessageEntry = memo\(function RectificationMessageEntry/);
|
|
const propsBlock = entrySource.slice(
|
|
entrySource.indexOf("export type RectificationMessageEntryProps"),
|
|
entrySource.indexOf("export function choiceCardFromQuestion"),
|
|
);
|
|
assert.match(propsBlock, /actionsRef: MutableRefObject<RectificationMessageActions>/);
|
|
assert.doesNotMatch(propsBlock, /=>/);
|
|
assert.doesNotMatch(propsBlock, /on[A-Z]\w+\?:/);
|
|
assert.match(rowSource, /export const ChatMessageRow = memo\(function ChatMessageRow/);
|
|
});
|
|
|
|
test("the split architecture renders settled rectification rows once while streaming tokens", () => {
|
|
const history: RenderMessage[] = [
|
|
settled("user", "第一件经历", "u1"),
|
|
settled("assistant", "记下了第一件。", "a1"),
|
|
settled("user", "第二件经历", "u2"),
|
|
settled("assistant", "记下了第二件。", "a2"),
|
|
];
|
|
const tokens = ["甲", "甲乙", "甲乙丙", "甲乙丙丁", "甲乙丙丁戊"];
|
|
const shared = sharedProps();
|
|
const feedbackByKey: Readonly<Record<string, ChatMessageFeedback | undefined>> = {};
|
|
|
|
resetHomeStreamingRenderProbe();
|
|
enableHomeStreamingRenderProbe();
|
|
for (const message of history) {
|
|
renderToString(createElement(RectificationMessageEntry, entryProps(message)));
|
|
}
|
|
for (const streamingText of tokens) {
|
|
renderToString(createElement(RectificationMessageEntry, entryProps(liveAssistant(streamingText))));
|
|
}
|
|
const split = homeStreamingRenderProbeSnapshot();
|
|
disableHomeStreamingRenderProbe();
|
|
|
|
resetHomeStreamingRenderProbe();
|
|
enableHomeStreamingRenderProbe();
|
|
for (const streamingText of tokens) {
|
|
renderToString(createElement(UnsplitRectificationMessageList, {
|
|
...shared,
|
|
messages: [...history, liveAssistant(streamingText)],
|
|
latestRegeneratableKey: "a2",
|
|
copiedMessageKey: null,
|
|
feedbackByKey,
|
|
regeneratingMessageKey: null,
|
|
}));
|
|
}
|
|
const unsplit = homeStreamingRenderProbeSnapshot();
|
|
disableHomeStreamingRenderProbe();
|
|
|
|
assert.equal(split.settledRowRenders, history.length);
|
|
assert.ok(split.streamingRowRenders <= tokens.length);
|
|
assert.ok(split.streamingRowRenders >= 1);
|
|
assert.equal(unsplit.unsplitListRenders, tokens.length);
|
|
// Unsplit counts every row on every frame, including the live one.
|
|
assert.equal(unsplit.settledRowRenders, (history.length + 1) * tokens.length);
|
|
assert.ok(unsplit.settledRowRenders > split.settledRowRenders);
|
|
});
|