Merge pull request '修复 Agent 对话上滑时被自动拉回底部' (#54) from codex/agent-chat-scroll-20261106 into main
This commit is contained in:
commit
feaa18ed3b
@ -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(
|
||||
|
||||
198
tests/e2e/canvas-chat-scroll.test.ts
Normal file
198
tests/e2e/canvas-chat-scroll.test.ts
Normal 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\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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user