diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 346e94a8a..5dc342467 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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([]); const pendingCanvasSkillTagOrderSaveRef = useRef(null); const suppressSkillTagClickRef = useRef(false); - const [messageViewportKey, setMessageViewportKey] = useState(0); - const messagesEndRef = useRef(null); + const { scrollRef: messagesScrollRef, contentRef: messagesContentRef, scrollToBottom: scrollMessagesToBottom, isAtBottom: isMessageListAtBottom } = useStickToBottom({ initial: false, resize: "instant" }); + const initialScrollChatRef = useRef(null); const fileInputRef = useRef(null); const clawdFolderInputRef = useRef(null); const videoSlotInputRefs = useRef>([]); @@ -3404,7 +3405,6 @@ export function CanvasChatPanel({ const hasInitializedVideoWorkspaceRef = useRef(false); const sessionMenuRef = useRef(null); const clawdHistoryMenuRef = useRef(null); - const historyScrollTimerRef = useRef | null>(null); const forceHistoryHydrationRef = useRef(null); const copiedUserMessageRef = useRef(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(""); @@ -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 ? (
) : ( +
-
@@ -17358,9 +17344,22 @@ export function CanvasChatPanel({ /> ) : null} -
+ {!isMessageListAtBottom && renderableVisibleMessages.length > 0 ? ( + + ) : null} +
)} {fullscreenPreview && createPortal( diff --git a/tests/e2e/canvas-chat-scroll.test.ts b/tests/e2e/canvas-chat-scroll.test.ts new file mode 100644 index 000000000..f861aff8d --- /dev/null +++ b/tests/e2e/canvas-chat-scroll.test.ts @@ -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(resolve=>waiting.push(resolve)); + await route.fulfill({contentType:'image/svg+xml',body:''}); + }); + 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(); + }); +});