From b9fa304b1a434238671e81f6c436ec0662c22201 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 23 Jul 2026 16:30:21 +0800 Subject: [PATCH] fix: follow rectification replies to bottom --- docs/BUG_HISTORY.md | 20 ++++++- ...onversational-birth-time-rectification.tsx | 29 +++++++++- ...ersational-rectification-component.test.ts | 57 ++++++++++++++++++- 3 files changed, 99 insertions(+), 7 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a2cba9c7..5144ebc0 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -328,7 +328,7 @@ - 防复发:出生声明与 `birth_time_status` 必须由同一次服务端写入建立,不允许依赖后续校正流程补齐状态。 - 相关记录:BUG-009、BUG-017 - 复发自:无 -- 修复版本:待提交 +- 修复版本:本次自动滚动修复提交 ## BUG-019 | 原问题交接租约过期后永久显示处理中 @@ -680,4 +680,20 @@ - 防复发:Agent 活动状态只能描述消息生命周期,不得控制正文是否渲染;完成态必须保留稳定的视觉反馈,不能以卸载整个状态区域代替状态转换。 - 相关记录:BUG-039 - 复发自:无 -- 修复版本:待提交 +- 修复版本:`4a9b1dc` + +## BUG-041 | 生时校正生成回答时消息列表不自动跟随到底部 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生时校正消息历史、用户发送后的撤回窗口、Agent thinking / streaming / settled 回答阶段 +- 用户现象:用户发送经历或 Agent 开始生成回答后,新内容出现在消息列表下方,但列表停留在旧位置;用户必须手动向下滚动才能看到生成状态和最新回答。 +- 触发条件:生时校正已有足够内容使独立消息容器产生纵向滚动,然后发送新经历或接收流式 Agent 回答。 +- 根因:普通 session 有独立的底部跟随 effect,生时校正虽然使用可滚动的 `.rectification-message-list`,但组件没有保存该容器的 ref,也没有在消息、提交状态、流式文本或最终 turn 更新时调整容器滚动位置。 +- 修复:为生时校正消息容器增加专用 ref,并在用户消息、生成状态、流式增量、错误及最终 turn 生命周期变化后滚动该容器到底部;生成过程中直接跟随,完成后平滑定位,且尊重 reduced-motion,不调用会牵动外层页面的 `scrollIntoView`。 +- 验证:真实 Chromium 390px 回归制造独立消息容器 overflow,并分别断言初始历史、乐观用户消息、流式 Agent 增量和完成回答都保持在底部;相关聚焦测试、ESLint、TypeScript 与生产 smoke。 +- 防复发:生时校正的消息生命周期新增阶段必须进入底部跟随依赖;真实浏览器测试必须验证容器自身的 `scrollTop`,不能只检查正文是否出现在 DOM。 +- 相关记录:BUG-039、BUG-040 +- 复发自:无 +- 修复版本:本次自动滚动修复提交 diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index a76b23f3..95c505b6 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -1,7 +1,7 @@ "use client"; import { ArrowUp, Square } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { ChatMessageRow } from "./chat-message-row.tsx"; import { Button } from "./ui/button.tsx"; import { Textarea } from "./ui/textarea.tsx"; @@ -43,6 +43,7 @@ export function ConversationalRectificationSurface({ onContinueOriginalQuestion, }: SurfaceProps) { const composer = useRef(null); + const messageList = useRef(null); const undoTimer = useRef | null>(null); const [submission, setSubmission] = useState | null>(null); const turn = controller.turn; + useLayoutEffect(() => { + const list = messageList.current; + if (!list) return; + + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const followImmediately = controller.pending || submission !== null || prefersReducedMotion; + const frame = requestAnimationFrame(() => { + if (followImmediately) { + list.scrollTop = list.scrollHeight; + return; + } + list.scrollTo({ top: list.scrollHeight, behavior: "smooth" }); + }); + + return () => cancelAnimationFrame(frame); + }, [ + controller.error, + controller.messages?.length, + controller.pending, + controller.streamingAssistantText, + submission, + turn?.status, + turn?.turnVersion, + ]); useEffect(() => () => { if (undoTimer.current) clearTimeout(undoTimer.current); }, []); @@ -110,7 +135,7 @@ export function ConversationalRectificationSurface({ return (
-
+
{submission?.phase === "undo" ? "消息已发送,可以撤回修改" : controller.pending ? "Jyotisha 正在核对经历" : ""} {(controller.messages ?? [{ role: "assistant" as const, diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index d6523824..d2e8e37b 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -712,7 +712,14 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro }); const turns = { activeA1: makeTurn(caseA, 1), - activeA3: makeTurn(caseA, 3), + activeA3: { + ...makeTurn(caseA, 13), + messageHistory: Array.from({ length: 12 }, (_, index) => ({ + turnVersion: index + 1, + userMessage: "第 " + (index + 1) + " 条已发生经历,用于制造可滚动的真实对话历史。", + narrative: "已记录第 " + (index + 1) + " 条经历,并继续核对其他领域的候选差异。", + })), + }, activeB1: makeTurn(caseB, 1), abandonedB2: makeTurn(caseB, 2, "abandoned"), }; @@ -722,8 +729,16 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro const [initialTurn, setInitialTurn] = useState(null); const [transportLabel, setTransportLabel] = useState("first"); const [callbackLabel, setCallbackLabel] = useState("first"); - const send = async (command) => { + const send = async (command, options) => { events.push("send:" + transportLabel + ":" + command.type); + if (command.type === "answer") { + await new Promise((resolveSend) => setTimeout(resolveSend, 20)); + options?.onNarrativeDelta?.("正在结合这段经历核对候选。 "); + events.push("delta:first"); + await new Promise((resolveSend) => setTimeout(resolveSend, 20)); + options?.onNarrativeDelta?.("下一步会继续验证其他领域。"); + events.push("delta:second"); + } await new Promise((resolveSend) => setTimeout(resolveSend, 20)); if (command.type === "pause") { return makeTurn(command.caseId, command.turnVersion + 1, "paused"); @@ -767,7 +782,7 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro outfile: bundlePath, platform: "browser", }), 10_000, "browser fixture bundle"); - writeFileSync(htmlPath, `
`); + writeFileSync(htmlPath, `
`); ({ browser, cdp } = await launchFixture(htmlPath, userDataDirectory)); await cdp.send("Runtime.enable"); @@ -866,6 +881,42 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro "streamlined rectification controls", ); + const followsBottom = `(() => { + const list = document.querySelector('.rectification-message-list'); + return list.scrollHeight > list.clientHeight + && Math.abs(list.scrollHeight - list.clientHeight - list.scrollTop) <= 2; + })()`; + await waitFor( + () => cdp?.evaluate(followsBottom) ?? Promise.resolve(false), + "initial rectification history at the bottom", + ); + await cdp.evaluate("document.querySelector('.rectification-message-list').scrollTop = 0"); + const correctedAnswer = "2020年9月底主动离职,原因是组织内耗严重。"; + await cdp.evaluate(`(() => { + const textarea = document.getElementById('conversational-rectification-answer'); + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; + setter.call(textarea, ${JSON.stringify(correctedAnswer)}); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + document.querySelector('[aria-label="发送"]').click(); + })()`); + await waitFor( + () => cdp?.evaluate(followsBottom) ?? Promise.resolve(false), + "optimistic user message follows the bottom", + ); + await waitFor( + () => cdp?.evaluate(`globalThis.__rectificationHarness.events.includes('delta:first') + && document.body.textContent.includes('正在结合这段经历核对候选。') + && ${followsBottom}`) ?? Promise.resolve(false), + "streaming assistant delta follows the bottom", + ); + await waitFor( + () => cdp?.evaluate(`globalThis.__rectificationHarness.events.includes('delta:second') + && document.body.textContent.includes('回答已完成') + && document.body.textContent.includes(${JSON.stringify(correctedAnswer)}) + && ${followsBottom}`) ?? Promise.resolve(false), + "settled assistant answer follows the bottom", + ); + } finally { try { cdp?.close();