Merge pull request '修复 Agent 对话上滑时被自动拉回底部' (#54) from codex/agent-chat-scroll-20261106 into main

This commit is contained in:
WuYongJie 2026-11-06 13:03:14 +08:00
commit feaa18ed3b
2 changed files with 240 additions and 43 deletions

View File

@ -2,6 +2,7 @@
import { advancePastAtomicRanges, scanMarkdownImageStreaming } from "@/lib/canvas/markdown-image-streaming";
import { useChat } from "@ai-sdk/react";
import { useStickToBottom } from "use-stick-to-bottom";
import { DefaultChatTransport } from "ai";
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress";
@ -3367,8 +3368,8 @@ export function CanvasChatPanel({
const latestCanvasSkillTagsRef = useRef<CanvasSkillTag[]>([]);
const pendingCanvasSkillTagOrderSaveRef = useRef<string[] | null>(null);
const suppressSkillTagClickRef = useRef(false);
const [messageViewportKey, setMessageViewportKey] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { scrollRef: messagesScrollRef, contentRef: messagesContentRef, scrollToBottom: scrollMessagesToBottom, isAtBottom: isMessageListAtBottom } = useStickToBottom({ initial: false, resize: "instant" });
const initialScrollChatRef = useRef<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const clawdFolderInputRef = useRef<HTMLInputElement | null>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
@ -3404,7 +3405,6 @@ export function CanvasChatPanel({
const hasInitializedVideoWorkspaceRef = useRef(false);
const sessionMenuRef = useRef<HTMLDivElement>(null);
const clawdHistoryMenuRef = useRef<HTMLDivElement>(null);
const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const forceHistoryHydrationRef = useRef<string | null>(null);
const copiedUserMessageRef = useRef<CopiedUserMessagePayload | null>(null);
const composerUserEditedAtRef = useRef(0);
@ -8837,12 +8837,6 @@ export function CanvasChatPanel({
hasHydratedHistoryRef.current = true;
setMessages(sortCanvasMessagesByCreatedAt(dbMessages));
if (dbMessages.length > 0) {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
});
}
};
const reloadMessagesOnce = async () => {
@ -8955,7 +8949,7 @@ export function CanvasChatPanel({
onRunChange: (run) => {
// The live stream owns its message; polling only recovers disconnected replies.
if (status === "submitted" || status === "streaming") return;
if (!run?.assistantMessageId || !run.assistantText || run.chatId !== activeChatIdRef.current) return;
if (!run?.assistantMessageId || !run.assistantText || run.chatId !== toRawCanvasChatId(activeChatIdRef.current)) return;
// Recover the same assistant reply after a stream disconnect or refresh.
setMessages(current => {
const index = current.findIndex(message => message.id === run.assistantMessageId);
@ -9128,6 +9122,7 @@ export function CanvasChatPanel({
},
} as ChatMessage;
void scrollMessagesToBottom({ animation: "instant" });
sendMessage(launchUserMessage as any);
setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([
@ -9147,7 +9142,7 @@ export function CanvasChatPanel({
if (editableDiv) {
editableDiv.innerHTML = "";
}
}, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode]);
}, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode, scrollMessagesToBottom]);
// 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新)
const loadedHistorySignatureRef = useRef<string>("");
@ -9167,7 +9162,6 @@ export function CanvasChatPanel({
hiddenPromptRef.current = null;
autoContextRef.current = null;
pendingLaunchRef.current = null;
setMessageViewportKey((prev) => prev + 1);
setMessages([]);
setInput("");
setAttachments([]);
@ -9196,11 +9190,6 @@ export function CanvasChatPanel({
}, [activeChatId, activeComposerMode, input, insertImageTagInEditor]);
useEffect(() => {
if (historyScrollTimerRef.current) {
clearTimeout(historyScrollTimerRef.current);
historyScrollTimerRef.current = null;
}
if (!historyData || !rawChatId) return;
const historyMessages = historyData.messages || [];
const isClawdHistory = isClawdMode || historyMessages.some(isClawdChatMessage);
@ -9241,24 +9230,9 @@ export function CanvasChatPanel({
missingPendingMessage ? [...historyMessages, pendingMessage] : historyMessages
);
});
setMessageViewportKey((prev) => prev + 1);
console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature);
if (historyMessages.length > 0) {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
});
historyScrollTimerRef.current = setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
historyScrollTimerRef.current = null;
}, 160);
return () => {
if (historyScrollTimerRef.current) {
clearTimeout(historyScrollTimerRef.current);
historyScrollTimerRef.current = null;
}
};
}
}, [activeChatId, activeClawdRequestUi?.userMessageId, historyData, isClawdMode, isClawdSubmitting, rawChatId, setMessages, status]);
useEffect(() => {
@ -9838,11 +9812,13 @@ export function CanvasChatPanel({
});
}, [activeChatSessionMode, messages, nodes, updateNode]);
// 滚动到底部
// Open each conversation at its latest message; later updates respect manual scrolling.
useEffect(() => {
if (messages.length === 0 && !isClawdSubmitting) return;
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [isClawdSubmitting, messages]);
if (initialScrollChatRef.current === activeChatId) return;
initialScrollChatRef.current = activeChatId;
void scrollMessagesToBottom({ animation: "instant" });
}, [activeChatId, isClawdSubmitting, messages.length, scrollMessagesToBottom]);
// 标记是否已经为当前画布设置过标题(避免重复调用)
const hasSetCanvasTitle = useRef(false);
@ -12611,6 +12587,7 @@ export function CanvasChatPanel({
const supplement = typeof overrideInput === "string" ? overrideInput.trim() : input.trim();
if (isCanvasAgentRunning && !isClawdMode && imageAgentEnabled) {
if (supplement && await canvasAgentTask.control("steer", supplement)) {
void scrollMessagesToBottom({ animation: "instant" });
setInput("");
const editor = textareaRef.current as HTMLDivElement | null;
if (editor) editor.innerHTML = "";
@ -12908,6 +12885,7 @@ export function CanvasChatPanel({
requestChatId,
clawdRequestRestoreSnapshot
);
void scrollMessagesToBottom({ animation: "instant" });
startClawdRequestUi(requestChatId, userMessageId, nextProcessingHint);
setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([
@ -13440,6 +13418,7 @@ export function CanvasChatPanel({
// 使用与 multimodal-input 相同的格式发送消息
try {
void scrollMessagesToBottom({ animation: "instant" });
sendMessage(optimisticUserMessage as any);
setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([
@ -13478,6 +13457,7 @@ export function CanvasChatPanel({
}
}, [
isCanvasAgentRunning,
scrollMessagesToBottom,
onBeforeAgentRequest,
isVideoWorkspace,
canvasAgentTask.control,
@ -15711,7 +15691,7 @@ export function CanvasChatPanel({
processedMessageIds.current.clear();
hasHydratedHistoryRef.current = true;
setMessages(sortCanvasMessagesByCreatedAt(fresh.messages));
setMessageViewportKey((previous) => previous + 1);
void scrollMessagesToBottom({ animation: "instant" });
}).catch((error) => {
console.error("[CanvasChat] Failed to reopen current conversation:", error);
toast.error("聊天记录暂时读取失败,请重试");
@ -15725,7 +15705,7 @@ export function CanvasChatPanel({
replaceChatSessionQuery(sessionId);
setIsSessionMenuOpen(false);
toast.success("已切换聊天窗口");
}, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, setMessages, status]);
}, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, scrollMessagesToBottom, setMessages, status]);
const switchToClawdHistorySession = useCallback(
(historyItem: ClawdHistoryItem) => {
@ -16142,11 +16122,17 @@ export function CanvasChatPanel({
{isDetailEmptyState ? (
<div className="flex-1 min-h-0" />
) : (
<div key={activeChatId} className="relative flex min-h-0 flex-1 flex-col">
<div
key={`${activeChatId}-${messageViewportKey}`}
ref={messagesScrollRef}
data-testid="canvas-chat-messages"
role="region"
aria-label="对话消息"
// biome-ignore lint/a11y/noNoninteractiveTabindex: The scroll region needs PageUp/PageDown keyboard input.
tabIndex={0}
className={cn(
"flex-1 min-h-0 overflow-x-hidden",
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-y-auto",
"flex-1 min-h-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-auto",
isWorkspacePresentation ? "p-5" : "p-3",
isPlainCanvasEmptyState
? "pt-0"
@ -16155,7 +16141,7 @@ export function CanvasChatPanel({
: "pt-3"
)}
>
<div className={cn(
<div ref={messagesContentRef} className={cn(
"space-y-5 overflow-x-hidden pr-1",
isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24"
)}>
@ -17358,9 +17344,22 @@ export function CanvasChatPanel({
/>
) : null}
<div ref={messagesEndRef} />
</div>
</div>
{!isMessageListAtBottom && renderableVisibleMessages.length > 0 ? (
<Button
type="button"
variant="outline"
size="icon"
aria-label="回到最新消息"
title="回到最新消息"
className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full shadow-sm"
onClick={() => void scrollMessagesToBottom({ animation: "instant" })}
>
<ChevronDown className="h-4 w-4" />
</Button>
) : null}
</div>
)}
{fullscreenPreview && createPortal(

View File

@ -0,0 +1,198 @@
import { expect, test, type Page } from "@playwright/test";
const projectId="f1827b8f-b810-44d8-9927-c2ef255eed78";
const paragraph="保持产品结构与材质一致,先分析参考图片,再安排场景与光线,逐项确认处理要求。";
const makeMessage=(id:string,role:string,text:string,offset:number)=>({id,role,parts:[{type:"text",text}],createdAt:new Date(Date.UTC(2026,10,6,0,0,offset)).toISOString()});
async function setup(page:Page) {
const state={history:Array.from({length:16},(_,i)=>makeMessage(`history-${i}`,i%2?"assistant":"user",`历史记录 ${i+1}\n\n${new Array(4).fill(paragraph).join("\n\n")}`,i)),historyReads:0,run:null as any};
const errors:string[]=[];page.on("pageerror",error=>errors.push(error.message));
await page.context().addInitScript(()=>{
localStorage.setItem("theme","light");
const originalFetch=window.fetch.bind(window);
(window as any).agentScrollStream={};
window.fetch=async(input,init)=>{
const url=typeof input==="string"?input:input instanceof URL?input.href:input.url;
if(new URL(url,location.origin).pathname!=="/api/canvas/agent-chat"||init?.method!=="POST")return originalFetch(input,init);
const request=JSON.parse(String(init.body));
const stream=(window as any).agentScrollStream;
stream.request=request;stream.text="";
const encoder=new TextEncoder();
return new Response(new ReadableStream({start(controller){
const emit=(value:unknown)=>controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
emit({type:"start",messageId:"stream-assistant"});emit({type:"text-start",id:"text"});
stream.append=(text:string)=>{stream.text+=text;emit({type:"text-delta",id:"text",delta:text});};
stream.finish=()=>{emit({type:"text-end",id:"text"});emit({type:"finish"});controller.enqueue(encoder.encode("data: [DONE]\n\n"));controller.close();};
}}),{headers:{"Content-Type":"text/event-stream","x-vercel-ai-ui-message-stream":"v1"}});
};
});
await page.context().route("**/api/**",async route=>{
const request=route.request(),url=new URL(request.url()),path=url.pathname;
let data:unknown={};
if(path===`/api/canvas/${projectId}`)data={success:true,data:{id:projectId,title:"滚动验证",updatedAt:"2026-11-06T00:00:00.000Z",canvasData:{nodes:[],edges:[],viewport:{x:0,y:0,zoom:1}}}};
else if(path==="/api/auth/session")data={user:{id:"scroll-user",name:"滚动验证",type:"admin"},expires:"2099-01-01"};
else if(path.includes("validate-session"))data={success:true,valid:true};
else if(path==="/api/site-settings")data={canvasEnabled:true,agentEnabled:true,membershipEnabled:false};
else if(path==="/api/app-version")data={version:"development"};
else if(path==="/api/models")data={models:[{id:"image-test",modelId:"gpt-image-2",providerType:"openai",name:"GPT Image",type:"image",enabled:true,supportsImageGen:true}]};
else if(path==="/api/vote")data=[];
else if(path==="/api/canvas/agent-runs")data={run:state.run};
else if(path.includes("messages")){state.historyReads++;data={messages:url.searchParams.get("chatId")===projectId?state.history:[]};}
else if(path.includes("sessions"))data={sessions:[]};
else if(path.startsWith("/api/canvas/"))data={success:true,runs:[]};
await route.fulfill({contentType:"application/json",body:JSON.stringify(data)});
});
await page.goto(`/canvas/${projectId}`,{waitUntil:"domcontentloaded"});
await expect(page.getByTestId("canvas-chat-messages")).toContainText("历史记录 16");
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
return {state,errors};
}
const metrics=(page:Page)=>page.getByTestId("canvas-chat-messages").evaluate(element=>({top:element.scrollTop,bottom:element.scrollHeight-element.clientHeight-element.scrollTop}));
async function startStream(page:Page){
const editor=page.getByLabel("描述你的设计需求",{exact:true});await editor.fill("先分析和规划,再处理图片。");await editor.press("Enter");
await expect.poll(()=>page.evaluate(()=>Boolean((window as any).agentScrollStream.append))).toBe(true);
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`开始分析\n\n${new Array(8).fill(paragraph).join("\n\n")}\n\n首次输出已显示\n\n`);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("首次输出已显示");
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
}
async function scrollUp(page:Page){
const box=(await page.getByTestId("canvas-chat-messages").boundingBox())!;
await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.wheel(0,-650);
await expect.poll(async()=>(await metrics(page)).bottom).toBeGreaterThan(350);
await page.waitForTimeout(100);
return (await metrics(page)).top;
}
test("streaming Agent output preserves the position after the reader scrolls up",async({page})=>{
const {errors}=await setup(page);await startStream(page);const top=await scrollUp(page);
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`新增分析步骤\n\n${new Array(5).fill(paragraph).join("\n\n")}`);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("新增分析步骤");
await page.waitForTimeout(400);
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
expect(errors).toEqual([]);
});
test("returning to the latest message resumes following streamed output",async({page})=>{
const {errors}=await setup(page);await startStream(page);await scrollUp(page);
await expect(page.getByRole("button",{name:"回到最新消息",exact:true})).toBeVisible();
await page.screenshot({path:test.info().outputPath("reading-history-desktop.png"),animations:"disabled"});
await page.getByRole("button",{name:"回到最新消息",exact:true}).click();
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`继续处理\n\n${new Array(5).fill(paragraph).join("\n\n")}`);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("继续处理");
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
await scrollUp(page);
const box=(await page.getByTestId("canvas-chat-messages").boundingBox())!;
await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.wheel(0,20000);
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`已回到最新消息\n\n${paragraph}`);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("已回到最新消息");
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
expect(errors).toEqual([]);
});
test("finishing a streamed request and reloading its history preserve the reader and viewport",async({page})=>{
const {state,errors}=await setup(page);await startStream(page);const top=await scrollUp(page);
await page.getByTestId("canvas-chat-messages").evaluate(element=>(window as any).readingViewport=element);
const stream=await page.evaluate(()=>({request:(window as any).agentScrollStream.request,text:(window as any).agentScrollStream.text}));
state.history.push(stream.request.message,{...makeMessage("stream-assistant","assistant",stream.text,100),createdAt:new Date(Date.now()+100).toISOString()});
const reads=state.historyReads;
await page.evaluate(()=>(window as any).agentScrollStream.finish());
await expect(page.getByLabel("描述你的设计需求",{exact:true})).toBeEditable();
await expect.poll(()=>state.historyReads).toBeGreaterThan(reads);
await page.waitForTimeout(3800);
expect(await page.getByTestId("canvas-chat-messages").evaluate(element=>element===(window as any).readingViewport)).toBe(true);
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
expect(errors).toEqual([]);
});
test("background Agent progress restores text in the original chat without taking over scrolling",async({page})=>{
const {state,errors}=await setup(page);const top=await scrollUp(page);
const text=state.history.at(-1)!.parts[0].text+"\n\n后台任务继续分析参考图片。";
state.run={id:"native-scroll-run",chatId:projectId,projectId,assistantMessageId:state.history.at(-1)!.id,assistantText:text,status:"running",message:"正在分析图片",steps:2,toolCount:2,resultNodeIds:[],inputs:[],updatedAt:new Date().toISOString(),cancelRequested:false,resumable:false};
await page.evaluate(run=>window.dispatchEvent(new CustomEvent("canvas-agent-run:update",{detail:run})),state.run);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("后台任务继续分析参考图片。");
await page.waitForTimeout(100);
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
state.run={...state.run,status:"completed",assistantText:text+"\n\n后台任务处理结束。"};
await page.evaluate(run=>window.dispatchEvent(new CustomEvent("canvas-agent-run:update",{detail:run})),state.run);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("后台任务处理结束。");
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
expect(errors).toEqual([]);
});
test("background history refresh preserves the same scroll container and reading position",async({page})=>{
await page.clock.install();
const {state,errors}=await setup(page);const top=await scrollUp(page);
await page.getByTestId("canvas-chat-messages").evaluate(element=>(window as any).readingViewport=element);
state.history.at(-1)!.parts[0].text+="\n\n历史记录已从服务端更新。";
const reads=state.historyReads;
await page.clock.fastForward(11000);
await page.evaluate(()=>window.dispatchEvent(new Event("focus")));
await expect.poll(()=>state.historyReads).toBeGreaterThan(reads);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("历史记录已从服务端更新。");
expect(await page.getByTestId("canvas-chat-messages").evaluate(element=>element===(window as any).readingViewport)).toBe(true);
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
expect(errors).toEqual([]);
});
test("a generated preview finishing its load does not pull a reader back to the bottom",async({page})=>{
let ready=false;let requests=0;const waiting:Array<()=>void>=[];
await page.context().route('https://scqnycdn.jiansun.vip/**',async route=>{
requests++;
if(!ready)await new Promise<void>(resolve=>waiting.push(resolve));
await route.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1200"><rect width="800" height="1200" fill="#43b9bd"/></svg>'});
});
const {errors}=await setup(page);await startStream(page);
await page.evaluate(()=>(window as any).agentScrollStream.append('\n\n![生成结果](https://scqnycdn.jiansun.vip/uploads/scroll-result.png)\n\n'));
await expect.poll(()=>requests).toBeGreaterThan(0);
const top=await scrollUp(page);ready=true;waiting.forEach(release=>release());
await expect.poll(()=>page.getByTestId('canvas-chat-messages').locator('img').evaluateAll(images=>images.some(image=>(image as HTMLImageElement).naturalWidth>0))).toBe(true);
await page.waitForTimeout(200);
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
expect(errors).toEqual([]);
});
test("keyboard scrolling pauses following and a new conversation starts at its own latest message",async({page})=>{
const {errors}=await setup(page);await startStream(page);
await page.getByTestId("canvas-chat-messages").press("PageUp");
await expect.poll(async()=>(await metrics(page)).bottom).toBeGreaterThan(100);
const top=(await metrics(page)).top;
await page.evaluate(()=>(window as any).agentScrollStream.append("\n\n键盘上翻后的新进度。"));
await expect(page.getByTestId("canvas-chat-messages")).toContainText("键盘上翻后的新进度。");
await page.waitForTimeout(200);
expect((await metrics(page)).top).toBeLessThanOrEqual(top+4);
await page.getByRole("button",{name:"新建聊天窗口",exact:true}).click();
await expect(page.getByTestId("canvas-chat-messages")).not.toContainText("历史记录 1");
await expect(page.getByLabel("描述你的设计需求",{exact:true})).toBeEditable();
await startStream(page);
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
expect(errors).toEqual([]);
});
test.describe("mobile touch reading",()=>{
test.use({viewport:{width:390,height:844},isMobile:true,hasTouch:true});
test("touch scrolling keeps history readable while the Agent continues streaming",async({page})=>{
const {errors}=await setup(page);await startStream(page);
const box=(await page.getByTestId("canvas-chat-messages").boundingBox())!;
const cdp=await page.context().newCDPSession(page);
const x=box.x+box.width/2,y=box.y+70,delta=Math.min(260,box.height-120);
await cdp.send("Input.dispatchTouchEvent",{type:"touchStart",touchPoints:[{x,y}]});
for(let i=1;i<=8;i++){
await cdp.send("Input.dispatchTouchEvent",{type:"touchMove",touchPoints:[{x,y:y+delta*i/8}]});
await page.waitForTimeout(25);
}
await cdp.send("Input.dispatchTouchEvent",{type:"touchEnd",touchPoints:[]});
await expect.poll(async()=>(await metrics(page)).bottom).toBeGreaterThan(100);
const top=(await metrics(page)).top;
await page.evaluate(()=>(window as any).agentScrollStream.append("\n\n手机查看历史时的新进度。"));
await expect(page.getByTestId("canvas-chat-messages")).toContainText("手机查看历史时的新进度。");
await page.waitForTimeout(300);
expect((await metrics(page)).top).toBeLessThanOrEqual(top+4);
await expect(page.getByRole("button",{name:"回到最新消息",exact:true})).toBeVisible();
await page.screenshot({path:test.info().outputPath("reading-history-mobile.png"),animations:"disabled"});
await page.getByRole("button",{name:"回到最新消息",exact:true}).click();
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
expect(errors).toEqual([]);await cdp.detach();
});
});