fix(rectification): satisfy React compiler lint for collect stem attach
Independent Staging Quality Gate / validate (push) Successful in 9m21s
Independent Staging Quality Gate / publish (push) Successful in 1m49s

Move the current-question ref off render and persist the collect stem from snapshot/send callbacks so ESLint no longer fails the staging gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-02 17:05:29 +08:00
parent b1d4796d30
commit 33d55b3a54
3 changed files with 58 additions and 33 deletions
+2 -2
View File
@@ -7514,8 +7514,8 @@
- 用户现象:开场气泡只有打招呼,采集题干仍出现在点赞/复制/重生成图标下面。staging 已是 BUG-487 的 SHA。
- 触发条件:新 Case opening`current_question.kind=collect_spoken`GET 已有 prompt。
- 根因:BUG-487 把题干接到 `assistant_message` 并允许槽位兜底。直播正文仍可能只有打招呼(replace 未进客户端 raw、或 GET 后才有 current_question),于是 `showCollectSpokenPrompt` 把同一句画在图标下。用户要的是气泡正文,不是槽。
- 修复:不再渲染采集题槽。最新助手气泡按题干精确身份 `composeCollectSpokenAssistantText`;定稿 snapshot 把组合文本写进该条消息状态,复制走组合文本。选择卡仍走问题槽。模型仍不自己提问,避免气泡里两句相近问法。不 bump Skill,不放宽确认门。
- 验证:`rectification-spoken-collect` 锁气泡拼接、禁止 `rectification-question-slot__prompt`;既有 collect-prompt / v9-agent / regenerate / voice 锁保持。staging 门禁 run 2304 因 `showActivity={bubbleMessage.state…}` 对不上 `rectification-agentic-entry` 源码锁失败;`showActivity` 改回 `displayedMessage.state`(气泡仍走 `bubbleMessage`,该文件与 spoken-collect 本地 52/52
- 修复:不再渲染采集题槽。最新助手气泡按题干精确身份 `composeCollectSpokenAssistantText`;定稿后的 Case snapshot、发送下一条、以及挂载后的 snapshot 回调把组合文本写进该条消息状态,复制走组合文本。选择卡仍走问题槽。模型仍不自己提问,避免气泡里两句相近问法。不 bump Skill,不放宽确认门。
- 验证:`rectification-spoken-collect` 锁气泡拼接、禁止 `rectification-question-slot__prompt`;既有 collect-prompt / v9-agent / regenerate / voice 锁保持。staging 门禁 run 2304 因 `showActivity={bubbleMessage.state…}` 对不上 `rectification-agentic-entry` 源码锁失败;`showActivity` 改回 `displayedMessage.state`(气泡仍走 `bubbleMessage`。run 2305 前端 2509/2509 后 ESLint 在 `currentQuestionRef.current = currentQuestion`render)和 snapshot `setMessages` effect 上报 `react-hooks/refs` / `react-hooks/set-state-in-effect`ref 改到 `useLayoutEffect`,题干写入改到 snapshot/send 回调
- 防复发:口述采集题干不得作为动作图标下的兄弟节点。不得用正文字符串判断「有没有问过」。不得同时让模型提问又拼接同一句服务端题干。
- 相关记录:BUG-471、BUG-485、BUG-487
- 复发自:BUG-487(服务端拼接后槽位兜底仍可见)
@@ -120,6 +120,25 @@ function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | nul
};
}
function collectSpokenPromptFromQuestion(question: CurrentQuestionModel | null): string | null {
return question?.kind === "collect_spoken" ? question.prompt : null;
}
function attachCollectSpokenStem(current: RenderMessage[], prompt: string): RenderMessage[] {
const last = [...current].reverse().find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& !message.failed
&& Boolean(message.text)
));
if (!last) return current;
const combined = composeCollectSpokenAssistantText(last.text, prompt);
if (combined === last.text) return current;
return current.map((item) => (
item.renderKey === last.renderKey ? { ...item, text: combined } : item
));
}
function RectificationCandidateCards({
result,
@@ -324,7 +343,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const runAbort = useRef<AbortController | null>(null);
const choiceActionIds = useRef(new Map<string, string>());
const currentQuestionRef = useRef(currentQuestion);
currentQuestionRef.current = currentQuestion;
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
@@ -335,6 +353,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
// new cards land the viewport on the bottom only while the reader is there.
const conversationAnchor = useConversationScrollAnchor(conversation, true, caseId);
useLayoutEffect(() => {
currentQuestionRef.current = currentQuestion;
}, [currentQuestion]);
useLayoutEffect(() => {
const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`);
const update = () => {
@@ -399,16 +421,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}, []);
const loadCaseSnapshot = useCallback(async () => {
const loadCaseSnapshot = useCallback(async (): Promise<CurrentQuestionModel | null | undefined> => {
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store" },
);
if (!response.ok) return;
applyCaseSnapshot(await response.json().catch(() => null));
if (!response.ok) return undefined;
const payload = await response.json().catch(() => null);
applyCaseSnapshot(payload);
return currentQuestionFromSnapshot(payload?.current_question);
} catch {
// Snapshot refresh is best-effort; the durable Case remains on the server.
return undefined;
}
}, [applyCaseSnapshot, caseId, sessionId]);
@@ -420,7 +445,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
)
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
if (!controller.signal.aborted) applyCaseSnapshot(payload);
if (controller.signal.aborted) return;
applyCaseSnapshot(payload);
const prompt = collectSpokenPromptFromQuestion(currentQuestionFromSnapshot(payload?.current_question));
if (prompt) setMessages((current) => attachCollectSpokenStem(current, prompt));
})
.catch(() => {
// Snapshot refresh is best-effort; the durable Case remains on the server.
@@ -428,25 +456,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return () => controller.abort();
}, [applyCaseSnapshot, caseId, sessionId]);
useEffect(() => {
const prompt = currentQuestion?.kind === "collect_spoken" ? currentQuestion.prompt : null;
if (!prompt || busy || regeneratingMessageKey !== null) return;
setMessages((current) => {
const last = [...current].reverse().find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& !message.failed
&& Boolean(message.text)
));
if (!last) return current;
const combined = composeCollectSpokenAssistantText(last.text, prompt);
if (combined === last.text) return current;
return current.map((item) => (
item.renderKey === last.renderKey ? { ...item, text: combined } : item
));
});
}, [busy, currentQuestion, regeneratingMessageKey]);
const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => {
const trimmed = action === "message" ? messageText.trim() : "";
if ((action === "message" && !trimmed) || busy || readonly) return;
@@ -459,8 +468,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const userRenderKey = `v9-user-${turnKey}`;
const assistantRenderKey = `v9-assistant-${turnKey}`;
const pendingCollectPrompt = collectSpokenPromptFromQuestion(currentQuestionRef.current);
setMessages((current) => [
...current,
...(action === "message" && pendingCollectPrompt
? attachCollectSpokenStem(current, pendingCollectPrompt)
: current),
...(action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
@@ -667,9 +679,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined };
const succeeded = completed && !streamFailed && Boolean(parsed.text);
const collectPrompt = currentQuestionRef.current?.kind === "collect_spoken"
? currentQuestionRef.current.prompt
: null;
const collectPrompt = collectSpokenPromptFromQuestion(currentQuestionRef.current);
const settledText = succeeded && collectPrompt
? composeCollectSpokenAssistantText(parsed.text, collectPrompt)
: parsed.text;
@@ -708,12 +718,20 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setError((current) => current || userFacingRunFailure("run_failed"));
}
if (succeeded) {
const snapshotPrompt = collectSpokenPromptFromQuestion(await loadCaseSnapshot() ?? null);
const attachedText = snapshotPrompt
? composeCollectSpokenAssistantText(parsed.text, snapshotPrompt)
: settledText;
if (attachedText !== settledText) {
setMessages((current) => current.map((message) => (
message.renderKey === assistantRenderKey ? { ...message, text: attachedText } : message
)));
}
onMessagesChange?.([
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
{ role: "assistant", text: settledText },
{ role: "assistant", text: attachedText },
]);
onCompleted?.();
await loadCaseSnapshot();
}
} catch (caught) {
frames.settle();
@@ -1010,8 +1028,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (!response.ok || payload?.ok !== true || typeof payload.assistantMessage !== "string") {
throw new Error(payload?.message || payload?.error || "暂时无法重新生成回答");
}
const collectPrompt = collectSpokenPromptFromQuestion(currentQuestion);
const nextText = collectPrompt
? composeCollectSpokenAssistantText(payload.assistantMessage, collectPrompt)
: payload.assistantMessage;
setMessages((current) => current.map((item) => item.renderKey === message.renderKey
? { ...item, text: payload.assistantMessage, state: "settled" }
? { ...item, text: nextText, state: "settled" }
: item));
} catch (caught) {
setMessages((current) => current.map((item) => item.renderKey === message.renderKey
@@ -114,6 +114,9 @@ test("collect_spoken stem is joined into the latest assistant bubble, never a sl
assert.match(chat, /composeCollectSpokenAssistantText\(displayedMessage\.text, collectSpokenPrompt\)/);
assert.match(chat, /composeCollectSpokenAssistantText\(last\.text, prompt\)/);
assert.match(chat, /composeCollectSpokenAssistantText\(parsed\.text, collectPrompt\)/);
assert.match(chat, /function attachCollectSpokenStem/);
assert.match(chat, /useLayoutEffect\(\(\) => \{\s*currentQuestionRef\.current = currentQuestion;/);
assert.doesNotMatch(chat, /\[busy, currentQuestion, regeneratingMessageKey\]/);
assert.doesNotMatch(chat, /showCollectSpokenPrompt/);
assert.doesNotMatch(chat, /rectification-question-slot__prompt/);
assert.doesNotMatch(chat, /collectSpokenPromptId/);