fix: follow rectification replies to bottom
This commit is contained in:
+18
-2
@@ -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
|
||||
- 复发自:无
|
||||
- 修复版本:本次自动滚动修复提交
|
||||
|
||||
@@ -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<HTMLTextAreaElement>(null);
|
||||
const messageList = useRef<HTMLDivElement>(null);
|
||||
const undoTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [submission, setSubmission] = useState<Readonly<{
|
||||
text: string;
|
||||
@@ -50,6 +51,30 @@ export function ConversationalRectificationSurface({
|
||||
turnVersion: number;
|
||||
}> | 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 (
|
||||
<section className="rectification-chat" aria-busy={busy} aria-label="生时校正对话">
|
||||
<div className="message-list rectification-message-list">
|
||||
<div ref={messageList} className="message-list rectification-message-list">
|
||||
<span className="sr-only" aria-live="polite">{submission?.phase === "undo" ? "消息已发送,可以撤回修改" : controller.pending ? "Jyotisha 正在核对经历" : ""}</span>
|
||||
{(controller.messages ?? [{
|
||||
role: "assistant" as const,
|
||||
|
||||
@@ -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, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}\nhtml,body{height:auto;overflow:auto}body{padding:12px}#root{width:100%;min-width:0}</style></head><body><main id="root"></main><script src="${pathToFileURL(bundlePath).href}"></script></body></html>`);
|
||||
writeFileSync(htmlPath, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}\nhtml,body{height:100%;overflow:hidden}body{box-sizing:border-box;padding:12px}#root{width:100%;height:100%;min-width:0}</style></head><body><main id="root"></main><script src="${pathToFileURL(bundlePath).href}"></script></body></html>`);
|
||||
|
||||
({ 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<boolean>(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<boolean>(followsBottom) ?? Promise.resolve(false),
|
||||
"optimistic user message follows the bottom",
|
||||
);
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(`globalThis.__rectificationHarness.events.includes('delta:first')
|
||||
&& document.body.textContent.includes('正在结合这段经历核对候选。')
|
||||
&& ${followsBottom}`) ?? Promise.resolve(false),
|
||||
"streaming assistant delta follows the bottom",
|
||||
);
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(`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();
|
||||
|
||||
Reference in New Issue
Block a user