From 3bf0fec0977d6b5045b2d94e3abe328a154bfc58 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 15 Sep 2026 14:34:58 +0800 Subject: [PATCH] fix: restore Claw history before waiting for active tasks --- components/canvas/CanvasChatPanel.tsx | 117 ++++++++++++------ .../claw-history-recovery-2026-09-15.md | 18 +++ scripts/verify-claw-history-ui.mjs | 89 +++++++++++++ 3 files changed, 185 insertions(+), 39 deletions(-) create mode 100644 docs/incidents/claw-history-recovery-2026-09-15.md create mode 100644 scripts/verify-claw-history-ui.mjs diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index d1da8a602..beededcc7 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -4485,13 +4485,18 @@ export function CanvasChatPanel({ }, []); // 从 API 加载历史消息 - const { data: fetchedHistoryData, mutate: mutateHistoryData } = + const { + data: fetchedHistoryData, + error: historyLoadError, + isLoading: isHistoryLoading, + mutate: mutateHistoryData, + } = useSWR( historyMessagesSWRKey, fetchCanvasHistoryMessages, { - revalidateOnFocus: false, - revalidateOnReconnect: false, + revalidateOnFocus: true, + revalidateOnReconnect: true, dedupingInterval: 10000, } ); @@ -8958,6 +8963,36 @@ export function CanvasChatPanel({ // 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新) const loadedHistorySignatureRef = useRef(""); + // Clear the previous session before hydrating a cached destination. + const processedMessageIds = useRef>(new Set()); + const hasHydratedHistoryRef = useRef(false); + + useEffect(() => { + if (!hasInitializedChatStateRef.current) { + hasInitializedChatStateRef.current = true; + return; + } + + loadedHistorySignatureRef.current = ""; + processedMessageIds.current.clear(); + hasHydratedHistoryRef.current = false; + hiddenPromptRef.current = null; + autoContextRef.current = null; + pendingLaunchRef.current = null; + setMessageViewportKey((prev) => prev + 1); + setMessages([]); + setInput(""); + setAttachments([]); + setClawdTableFiles([]); + setIsSessionMenuOpen(false); + setDetailSlotPickerIndex(null); + + const editableDiv = textareaRef.current as HTMLDivElement | null; + if (editableDiv) { + editableDiv.innerHTML = ""; + } + }, [activeChatId, setMessages]); + useEffect(() => { if (historyScrollTimerRef.current) { clearTimeout(historyScrollTimerRef.current); @@ -8965,9 +9000,11 @@ export function CanvasChatPanel({ } if (!historyData || !rawChatId) return; + const historyMessages = historyData.messages || []; + const isClawdHistory = isClawdMode || historyMessages.some(isClawdChatMessage); const shouldForceHydrate = forceHistoryHydrationRef.current === rawChatId; if ( - (status === "streaming" || status === "submitted" || isClawdSubmitting) && + (status === "streaming" || status === "submitted") && !shouldForceHydrate ) { return; @@ -8979,7 +9016,6 @@ export function CanvasChatPanel({ return; } - const historyMessages = historyData.messages || []; const signature = buildCanvasHistoryMessagesSignature(rawChatId, historyMessages); if (loadedHistorySignatureRef.current === signature) return; @@ -8988,7 +9024,21 @@ export function CanvasChatPanel({ processedMessageIds.current = new Set(); hasHydratedHistoryRef.current = true; forceHistoryHydrationRef.current = null; - setMessages(sortCanvasMessagesByCreatedAt(historyMessages)); + setMessages((currentMessages) => { + // A slow history read must not erase the Claw message just submitted. + // Preserve only this session's pending user message; persisted IDs win. + const pendingUserMessageId = isClawdHistory + ? activeClawdRequestUi?.userMessageId + : undefined; + const pendingMessage = pendingUserMessageId + ? currentMessages.find((message) => message.id === pendingUserMessageId) + : undefined; + const missingPendingMessage = pendingMessage && + !historyMessages.some((message) => message.id === pendingMessage.id); + return sortCanvasMessagesByCreatedAt( + missingPendingMessage ? [...historyMessages, pendingMessage] : historyMessages + ); + }); setMessageViewportKey((prev) => prev + 1); console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature); @@ -9007,7 +9057,7 @@ export function CanvasChatPanel({ } }; } - }, [activeChatId, historyData, isClawdSubmitting, rawChatId, setMessages, status]); + }, [activeChatId, activeClawdRequestUi?.userMessageId, historyData, isClawdMode, isClawdSubmitting, rawChatId, setMessages, status]); useEffect(() => { const streamState = streamImageGenerationStateByChatId[activeChatId]; @@ -9075,37 +9125,6 @@ export function CanvasChatPanel({ clearStreamImageGenerationStateForChat, ]); - // 监听消息变化,提取图片并添加到画布 - // 仅处理历史同步后的新消息,避免刷新后把历史图片重复回灌到画布 - const processedMessageIds = useRef>(new Set()); - const hasHydratedHistoryRef = useRef(false); - - useEffect(() => { - if (!hasInitializedChatStateRef.current) { - hasInitializedChatStateRef.current = true; - return; - } - - loadedHistorySignatureRef.current = ""; - processedMessageIds.current.clear(); - hasHydratedHistoryRef.current = false; - hiddenPromptRef.current = null; - autoContextRef.current = null; - pendingLaunchRef.current = null; - setMessageViewportKey((prev) => prev + 1); - setMessages([]); - setInput(""); - setAttachments([]); - setClawdTableFiles([]); - setIsSessionMenuOpen(false); - setDetailSlotPickerIndex(null); - - const editableDiv = textareaRef.current as HTMLDivElement | null; - if (editableDiv) { - editableDiv.innerHTML = ""; - } - }, [activeChatId, setMessages]); - useEffect(() => { if (!activeChatId) return; @@ -9150,6 +9169,8 @@ export function CanvasChatPanel({ }); }, [activeChatId, messages, updateChatSessions]); + // Only add newly generated media after history hydration; Claw artifacts + // remain in the conversation instead of being imported into the canvas. useEffect(() => { if (activeChatSessionMode === "clawd") return; if (!hasHydratedHistoryRef.current) return; @@ -15827,7 +15848,25 @@ export function CanvasChatPanel({ "space-y-5 overflow-x-hidden pr-1", isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24" )}> - {!isDetailGenerationMode && renderableVisibleMessages.length === 0 ? ( + {isClawdMode && renderableVisibleMessages.length === 0 && + (isHistoryLoading || historyLoadError) ? ( +
+ + {historyLoadError ? "聊天记录加载失败,请重试" : "正在加载聊天记录…"} + + {historyLoadError ? ( + + ) : null} +
+ ) : null} + {!isDetailGenerationMode && renderableVisibleMessages.length === 0 && + !(isClawdMode && (isHistoryLoading || historyLoadError)) ? ( isWorkspacePresentation ? (
diff --git a/docs/incidents/claw-history-recovery-2026-09-15.md b/docs/incidents/claw-history-recovery-2026-09-15.md new file mode 100644 index 000000000..14541d95e --- /dev/null +++ b/docs/incidents/claw-history-recovery-2026-09-15.md @@ -0,0 +1,18 @@ +# Claw 新建对话后返回历史记录空白(2026-09-15) + +## 原因与修复 + +使用真实 `useChat`、SWR 和 CanvasChatPanel 中的 effect 顺序复现:进入历史 A → 新建空白会话 → 返回缓存中的 A。原代码先把历史写入 SDK 消息列表,后执行会话切换清理,将列表清空;SWR 缓存命中且数据没有变化时,不会再次触发恢复。原版本回归结果为 `[]`,而缓存中仍有两条消息。 + +此外,历史同步被 `isClawdSubmitting` 阻塞,导致继续发送或恢复正在处理的 Claw 任务后,历史要等任务结束才显示。 + +- 将会话清理放到历史同步之前,保留既有目标 chatId 校验,避免缓存记录刚显示就被清除。 +- Claw 任务进行期间也允许显示历史;保留当前请求尚未出现在服务端响应中的用户消息,按 ID 去重,已有持久化消息优先。 +- 保留普通 SDK 流式输出和发送中的保护,避免用历史覆盖正在输出的内容。 +- 浏览器返回或网络恢复时重新校验历史;空白加载状态显示“正在加载聊天记录”,失败时提供重新加载。 + +## 验证 + +`node scripts/verify-claw-history-ui.mjs` 使用 Chrome、项目实际 React/AI SDK/SWR、从源文件按原始顺序提取的两个历史 effect,覆盖:缓存往返、Claw 进行中加载历史、用户消息去重、跨会话迟到响应隔离、普通流式输出保护、窗口返回刷新、断网重连刷新及失败后手动重试。 + +`node scripts/verify-claw-history-ui.mjs --source-ref=c024ae31` 可复现旧版缓存往返后消息列表为空的断言失败。测试使用合成消息和本地 HTTP 服务,不发送用户对话、不调用生图服务、不修改数据库。 diff --git a/scripts/verify-claw-history-ui.mjs b/scripts/verify-claw-history-ui.mjs new file mode 100644 index 000000000..4c9c5e33e --- /dev/null +++ b/scripts/verify-claw-history-ui.mjs @@ -0,0 +1,89 @@ +// Browser regression for the actual CanvasChatPanel history effects and SDK. +// Run: node scripts/verify-claw-history-ui.mjs [--source-ref=] +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +import {chromium} from '@playwright/test'; +import ts from 'typescript'; +const require=createRequire(import.meta.url); +const esbuild=require(require.resolve('esbuild',{paths:[require.resolve('tsx')]})); +const root=process.cwd(), sourcePath='components/canvas/CanvasChatPanel.tsx'; +const ref=process.argv.find(x=>x.startsWith('--source-ref='))?.split('=')[1]; +const source=ref?execFileSync('git',['show',`${ref}:${sourcePath}`],{encoding:'utf8'}):await fs.readFile(sourcePath,'utf8'); +const ast=ts.createSourceFile(sourcePath,source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX); +const effects=[];let swrDeclaration='',fetchDeclaration='',isClawdDeclaration=''; +function visit(node){ + if(ts.isExpressionStatement(node)&&ts.isCallExpression(node.expression)&&node.expression.expression.getText(ast)==='useEffect'){ + const text=node.getText(ast); + if(text.includes('hasInitializedChatStateRef.current')||text.includes('const shouldForceHydrate = forceHistoryHydrationRef.current'))effects.push({pos:node.pos,text}); + } + if(ts.isVariableStatement(node)&&node.getText(ast).includes('useSWR'))swrDeclaration=node.getText(ast); + if(ts.isFunctionDeclaration(node)&&node.name?.text==='fetchCanvasHistoryMessages')fetchDeclaration=node.getText(ast); + if(ts.isFunctionDeclaration(node)&&node.name?.text==='isClawdChatMessage')isClawdDeclaration=node.getText(ast); + ts.forEachChild(node,visit); +}visit(ast); +assert.equal(effects.length,2,'Expected the session reset and history hydration effects'); +assert(swrDeclaration&&fetchDeclaration&&isClawdDeclaration); +const noticeStart=source.indexOf('{isClawdMode && renderableVisibleMessages.length === 0 &&'); +const noticeEnd=source.indexOf('{!isDetailGenerationMode && renderableVisibleMessages.length === 0 &&',noticeStart); +const notice=noticeStart>=0&¬iceEnd>noticeStart?source.slice(noticeStart,noticeEnd):''; +const temp=await fs.mkdtemp(path.join(os.tmpdir(),'claw-history-ui-')); +const message=(id,text,role='assistant')=>({id,role,parts:[{type:'text',text}],createdAt:'2026-09-15T00:00:00Z',videoMetadata:{composerMode:'clawd'}}); +const histories={a:[message('a-user','旧问题','user'),message('a-answer','历史回复')],new:[],pending:[message('p-old','之前的消息')],regular:[message('r-old','持久化旧消息')]}; +const pending=message('pending-user','刚提交的新问题','user');pending.createdAt='2026-09-15T01:00:00Z'; +let pendingDelay=350,requests=0,failHistory=true; +const server=http.createServer(async(req,res)=>{ + const url=new URL(req.url,'http://localhost'); + if(url.pathname==='/history'){ + requests++;const id=url.searchParams.get('chatId');const payload=JSON.stringify({messages:histories[id]||[]}); + if(id==='error'){await new Promise(r=>setTimeout(r,500));if(failHistory){res.writeHead(500);res.end('{}');return;}} + if(id==='pending')await new Promise(r=>setTimeout(r,pendingDelay)); + res.setHeader('Content-Type','application/json');res.end(payload);return; + } + if(url.pathname==='/app.js'){res.setHeader('Content-Type','text/javascript');res.end(await fs.readFile(path.join(temp,'app.js')));return;} + res.setHeader('Content-Type','text/html');res.end('
'); +}); +const entry=`import React,{useState,useRef,useEffect}from'react';import{createRoot}from'react-dom/client';import{useChat}from'@ai-sdk/react';import useSWR from'swr';import{sortCanvasMessagesByCreatedAt,buildCanvasHistoryMessagesSignature}from'./components/canvas/chat-panel/canvas-chat-media'; +const fetcher=async url=>{const response=await fetch(url);if(!response.ok)throw new Error('history unavailable');return response.json()};${fetchDeclaration}\n${isClawdDeclaration} +function Button({children,onClick}){return } +function App(){ + const scenario=new URLSearchParams(location.search).get('case')||'cache'; + const [activeChatId,setActiveChatId]=useState(scenario==='pending'?'pending':scenario==='regular'?'regular':scenario==='error'?'error':'a'); + const rawChatId=activeChatId;const historyMessagesSWRKey='/history?chatId='+activeChatId; + ${swrDeclaration} + const historyData=fetchedHistoryData?.chatId===rawChatId?fetchedHistoryData:undefined; + const [isClawdSubmitting,setSubmitting]=useState(scenario==='pending'); + const [status,setStatus]=useState(scenario==='regular'?'streaming':'ready'); + const isClawdMode=scenario!=='regular';const activeClawdRequestUi=isClawdSubmitting?{userMessageId:'pending-user'}:null; + const {messages,setMessages}=useChat({id:activeChatId,messages:scenario==='pending'?[${JSON.stringify(pending)}]:scenario==='regular'?[{id:'stream',role:'assistant',parts:[{type:'text',text:'仍在输出'}]}]:historyData?.messages||[]}); + const hasInitializedChatStateRef=useRef(false),loadedHistorySignatureRef=useRef(''),processedMessageIds=useRef(new Set()),hasHydratedHistoryRef=useRef(false),hiddenPromptRef=useRef(null),autoContextRef=useRef(null),pendingLaunchRef=useRef(null),textareaRef=useRef(null),historyScrollTimerRef=useRef(null),messagesEndRef=useRef(null),forceHistoryHydrationRef=useRef(null),inFlightChatIdRef=useRef(null),inFlightRawChatIdRef=useRef(null); + const [,setMessageViewportKey]=useState(0),[,setInput]=useState(''),[,setAttachments]=useState([]),[,setClawdTableFiles]=useState([]),[,setIsSessionMenuOpen]=useState(false),[,setDetailSlotPickerIndex]=useState(null); + ${effects.sort((a,b)=>a.pos-b.pos).map(x=>x.text).join('\n')} + window.historyTest={messages,loaded:!!historyData,pending:isClawdSubmitting,switchTo(id){forceHistoryHydrationRef.current=id;setActiveChatId(id)},refresh(){return mutateHistoryData()},finish(){setSubmitting(false);setStatus('ready')}}; + const renderableVisibleMessages=messages;return
${notice}
{messages.map(m=>

{m.parts[0]?.text}

)}
{String(isClawdSubmitting)}
+}createRoot(document.getElementById('root')).render();`; +let browser; +try{ + await esbuild.build({stdin:{contents:entry,loader:'tsx',resolveDir:root},outfile:path.join(temp,'app.js'),bundle:true,jsx:'automatic',define:{'process.env.NODE_ENV':'"production"'}}); + await new Promise(r=>server.listen(0,'127.0.0.1',r));const base='http://127.0.0.1:'+server.address().port; + browser=await chromium.launch({channel:'chrome',headless:true}); + const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message)); + const ids=()=>page.locator('[data-message-id]').evaluateAll(nodes=>nodes.map(n=>n.getAttribute('data-message-id'))); + await page.goto(base);await page.waitForFunction(()=>window.historyTest?.loaded);assert.deepEqual(await ids(),['a-user','a-answer']); + await page.evaluate(()=>window.historyTest.switchTo('new'));await page.waitForFunction(()=>window.historyTest.loaded);assert.deepEqual(await ids(),[]); + const priorRequests=requests;await page.evaluate(()=>window.historyTest.switchTo('a'));await page.waitForTimeout(150); + assert.deepEqual(await ids(),['a-user','a-answer'],'Opening cached history after a new chat must not clear its messages');assert.equal(requests,priorRequests,'This case must exercise the cached response, not a fresh network response');console.log('PASS cached history survives new-chat round trip'); + await page.goto(base+'/?case=pending');await page.waitForFunction(()=>window.historyTest?.loaded);await page.waitForTimeout(100); + assert.equal(await page.locator('#pending').innerText(),'true');assert.deepEqual(await ids(),['p-old','pending-user'],'History must appear before the task finishes and preserve the optimistic user message');console.log('PASS history loads during an active Claw task'); + histories.pending.push({...pending,parts:[{type:'text',text:'服务器已保存的新问题'}]});await page.evaluate(()=>window.historyTest.refresh());await page.waitForTimeout(100);assert.deepEqual(await ids(),['p-old','pending-user']);assert.equal(await page.locator('[data-message-id="pending-user"]').innerText(),'服务器已保存的新问题');console.log('PASS persisted message replaces its optimistic copy without duplicates'); + pendingDelay=700;const read=page.evaluate(()=>window.historyTest.refresh());await page.waitForTimeout(100);await page.evaluate(()=>window.historyTest.switchTo('new'));await read;await page.waitForTimeout(100);assert.deepEqual(await ids(),[],'A late response from another chat must not be displayed');console.log('PASS late history cannot leak into another chat'); + await page.goto(base+'/?case=regular');await page.waitForFunction(()=>window.historyTest?.loaded);assert.deepEqual(await ids(),['stream'],'Regular SDK output must retain its streaming protection');console.log('PASS ordinary streaming messages remain protected'); + await page.goto(base);await page.waitForFunction(()=>window.historyTest?.loaded);histories.a.push(message('a-new','其他窗口保存的新回复'));await page.waitForTimeout(11000);await page.evaluate(()=>window.dispatchEvent(new Event('focus')));await page.waitForFunction(()=>window.historyTest.messages.some(x=>x.id==='a-new'));console.log('PASS returning to the browser refreshes history'); + histories.a.push(message('a-reconnect','重新连接后的消息'));await page.waitForTimeout(11000);await page.evaluate(()=>window.dispatchEvent(new Event('online')));await page.waitForFunction(()=>window.historyTest.messages.some(x=>x.id==='a-reconnect'));console.log('PASS reconnect refreshes history'); + if(notice){await page.goto(base+'/?case=error');await page.getByText('正在加载聊天记录…',{exact:true}).waitFor();await page.getByText('聊天记录加载失败,请重试',{exact:true}).waitFor();failHistory=false;histories.error=[message('retry-restored','重试恢复的记录')];await page.getByRole('button',{name:'重新加载',exact:true}).click();await page.locator('[data-message-id="retry-restored"]').waitFor();console.log('PASS loading, failure and retry states recover history');} + assert.deepEqual(errors,[]); +}finally{await browser?.close();server.close();await fs.rm(temp,{recursive:true,force:true});}