From dcff190c627d54dd4127edc7759286d357d2eb44 Mon Sep 17 00:00:00 2001 From: 134 Date: Fri, 11 Sep 2026 11:19:04 +0800 Subject: [PATCH] fix(claw): prevent new chat history rebound --- components/canvas/CanvasChatPanel.tsx | 123 ++++++++++++++---- .../canvas/chat-panel/canvas-chat-session.ts | 37 ++++++ tests/routes/canvas-chat-helpers.test.ts | 52 ++++++++ 3 files changed, 188 insertions(+), 24 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 82b86c089..e53bb72b7 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -275,8 +275,11 @@ import { MAX_CHAT_AUTO_CONTEXT_LENGTH, MAX_CHAT_HIDDEN_PROMPT_LENGTH, MAX_CHAT_TEXT_PART_LENGTH, + normalizeCanvasChatSessionId, normalizeChatAttachmentName, normalizeChatMediaType, + replaceCanvasChatSessionHistory, + resolveCanvasChatSessionNavigationTarget, type CanvasChatSessionRecord, type ClawdChatHistoryRecord, type PickedObjectAttachment, @@ -3055,15 +3058,24 @@ type CanvasChatSession = Omit & { type ClawdHistoryItem = ClawdChatHistoryRecord; -const CANVAS_CHAT_QUERY_PARAM = "chat"; const CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT = "canvas:image-node-manual-upload"; +const CANVAS_CHAT_QUERY_PARAM = "chat"; -function normalizeCanvasChatSessionId(value?: string | null) { - const raw = String(value || "").trim(); - if (!raw) return null; +type CanvasHistoryMessagesResponse = { + chatId: string; + messages: ChatMessage[]; +}; - const rawUuid = raw.startsWith("canvas-") ? raw.slice("canvas-".length) : raw; - return isValidChatUuid(rawUuid) ? `canvas-${rawUuid}` : null; +async function fetchCanvasHistoryMessages( + url: string +): Promise { + const payload = (await fetcher(url)) as { messages?: ChatMessage[] }; + const requestUrl = new URL(url, "http://canvas.local"); + + return { + chatId: String(requestUrl.searchParams.get("chatId") || ""), + messages: Array.isArray(payload?.messages) ? payload.messages : [], + }; } type CopiedUserMessagePayload = { @@ -3365,6 +3377,8 @@ export function CanvasChatPanel({ const pendingLaunchRef = useRef(null); const [launchAutoSendTick, setLaunchAutoSendTick] = useState(0); const hasInitializedChatStateRef = useRef(false); + const initializedChatSessionStorageKeyRef = useRef(null); + const chatSessionRootIdRef = useRef(chatId); const [loadedChatSessionStorageKey, setLoadedChatSessionStorageKey] = useState( null ); @@ -3392,12 +3406,12 @@ export function CanvasChatPanel({ const replaceChatSessionQuery = useCallback( (sessionId: string) => { - const normalized = normalizeCanvasChatSessionId(sessionId); - if (!normalized || typeof window === "undefined") return; - - const url = new URL(window.location.href); - url.searchParams.set(CANVAS_CHAT_QUERY_PARAM, normalized); - window.history.replaceState(window.history.state, "", url); + if (typeof window === "undefined") return; + replaceCanvasChatSessionHistory({ + currentUrl: window.location.href, + history: window.history, + sessionId, + }); }, [] ); @@ -3736,6 +3750,7 @@ export function CanvasChatPanel({ forceHistoryHydrationRef.current = null; releaseSendGuard(activeChatId); updateChatSessions((prevSessions) => [newSession, ...prevSessions]); + activeChatIdRef.current = newChatSessionId; setActiveChatId(newChatSessionId); replaceChatSessionQuery(newChatSessionId); toast.success(options?.toastMessage || "已新建聊天窗口"); @@ -4169,6 +4184,11 @@ export function CanvasChatPanel({ useEffect(() => { const now = Date.now(); let rootChatId = chatId; + const sessionStorageKey = getSessionStorageKey(canvasId, chatScope); + + if (initializedChatSessionStorageKeyRef.current === sessionStorageKey) { + return; + } if (chatScope === "agent") { try { @@ -4186,6 +4206,11 @@ export function CanvasChatPanel({ } } + chatSessionRootIdRef.current = rootChatId; + const initialRequestedChatSessionId = normalizeCanvasChatSessionId( + new URL(window.location.href).searchParams.get(CANVAS_CHAT_QUERY_PARAM) + ); + const fallbackSession: CanvasChatSession = { id: rootChatId, title: "对话 1", @@ -4194,17 +4219,17 @@ export function CanvasChatPanel({ updatedAt: now, }; const requestedSession: CanvasChatSession | null = - requestedChatSessionId && requestedChatSessionId !== rootChatId + initialRequestedChatSessionId && + initialRequestedChatSessionId !== rootChatId ? { - id: requestedChatSessionId, + id: initialRequestedChatSessionId, title: "新对话", mode: "image", createdAt: now, updatedAt: now, } : null; - const initialChatId = requestedChatSessionId || rootChatId; - const sessionStorageKey = getSessionStorageKey(canvasId, chatScope); + const initialChatId = initialRequestedChatSessionId || rootChatId; try { const saved = localStorage.getItem(sessionStorageKey); @@ -4248,6 +4273,7 @@ export function CanvasChatPanel({ const sorted = [...nextSessions].sort((a, b) => b.updatedAt - a.updatedAt); setChatSessions(sorted); + activeChatIdRef.current = initialChatId; setActiveChatId(initialChatId); localStorage.setItem(sessionStorageKey, JSON.stringify(sorted)); setLoadedChatSessionStorageKey(sessionStorageKey); @@ -4256,10 +4282,52 @@ export function CanvasChatPanel({ setChatSessions( requestedSession ? [requestedSession, fallbackSession] : [fallbackSession] ); + activeChatIdRef.current = initialChatId; setActiveChatId(initialChatId); setLoadedChatSessionStorageKey(sessionStorageKey); + } finally { + initializedChatSessionStorageKeyRef.current = sessionStorageKey; } - }, [canvasId, chatId, chatScope, requestedChatSessionId]); + }, [canvasId, chatId, chatScope]); + + useEffect(() => { + const sessionStorageKey = getSessionStorageKey(canvasId, chatScope); + if (initializedChatSessionStorageKeyRef.current !== sessionStorageKey) return; + + const browserRequestedChatSessionId = normalizeCanvasChatSessionId( + new URL(window.location.href).searchParams.get(CANVAS_CHAT_QUERY_PARAM) + ); + const targetChatId = resolveCanvasChatSessionNavigationTarget({ + activeSessionId: activeChatIdRef.current, + browserSessionId: browserRequestedChatSessionId, + rootSessionId: chatSessionRootIdRef.current, + routerSessionId: requestedChatSessionId, + }); + if (!targetChatId) return; + + const now = Date.now(); + updateChatSessions((previousSessions) => { + if (previousSessions.some((session) => session.id === targetChatId)) { + return previousSessions; + } + return [ + { + id: targetChatId, + title: "新对话", + mode: "image", + createdAt: now, + updatedAt: now, + }, + ...previousSessions, + ]; + }); + + forceHistoryHydrationRef.current = requestedChatSessionId + ? toRawCanvasChatId(targetChatId) + : null; + activeChatIdRef.current = targetChatId; + setActiveChatId(targetChatId); + }, [canvasId, chatScope, requestedChatSessionId, updateChatSessions]); useEffect(() => { if (!isSessionMenuOpen) return; @@ -4417,13 +4485,18 @@ export function CanvasChatPanel({ }, []); // 从 API 加载历史消息 - const { data: historyData, mutate: mutateHistoryData } = useSWR<{ - messages: ChatMessage[]; - }>(historyMessagesSWRKey, fetcher, { - revalidateOnFocus: false, - revalidateOnReconnect: false, - dedupingInterval: 10000, - }); + const { data: fetchedHistoryData, mutate: mutateHistoryData } = + useSWR( + historyMessagesSWRKey, + fetchCanvasHistoryMessages, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + dedupingInterval: 10000, + } + ); + const historyData = + fetchedHistoryData?.chatId === rawChatId ? fetchedHistoryData : undefined; const initialCanvasMessageCount = activeChatId === chatId ? initialMessages.length : 0; const voteSWRKey = @@ -15316,6 +15389,7 @@ export function CanvasChatPanel({ } forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId); releaseSendGuard(activeChatId); + activeChatIdRef.current = sessionId; setActiveChatId(sessionId); replaceChatSessionQuery(sessionId); setIsSessionMenuOpen(false); @@ -15365,6 +15439,7 @@ export function CanvasChatPanel({ forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId); releaseSendGuard(activeChatId); + activeChatIdRef.current = sessionId; setActiveChatId(sessionId); setComposerModeOverride("clawd"); setIsDetailGenerationMode(false); diff --git a/components/canvas/chat-panel/canvas-chat-session.ts b/components/canvas/chat-panel/canvas-chat-session.ts index caddd2375..d79de5159 100644 --- a/components/canvas/chat-panel/canvas-chat-session.ts +++ b/components/canvas/chat-panel/canvas-chat-session.ts @@ -9,6 +9,7 @@ export const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100; const CHAT_UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const CANVAS_CHAT_QUERY_PARAM = "chat"; const ALLOWED_CHAT_MEDIA_TYPES = new Set([ "image/jpeg", @@ -70,6 +71,42 @@ export function isValidChatUuid(value?: string | null): value is string { return Boolean(value && CHAT_UUID_REGEX.test(String(value).trim())); } +export function normalizeCanvasChatSessionId(value?: string | null) { + const raw = String(value || "").trim(); + if (!raw) return null; + + const rawUuid = raw.startsWith("canvas-") ? raw.slice("canvas-".length) : raw; + return isValidChatUuid(rawUuid) ? `canvas-${rawUuid}` : null; +} + +export function replaceCanvasChatSessionHistory(params: { + currentUrl: string; + history: Pick; + sessionId: string; +}) { + const normalized = normalizeCanvasChatSessionId(params.sessionId); + if (!normalized) return null; + + const url = new URL(params.currentUrl); + url.searchParams.set(CANVAS_CHAT_QUERY_PARAM, normalized); + + // Reusing Next.js' existing history state bypasses its URL synchronization. + params.history.replaceState(null, "", url); + return normalized; +} + +export function resolveCanvasChatSessionNavigationTarget(params: { + activeSessionId: string; + browserSessionId: string | null; + rootSessionId: string; + routerSessionId: string | null; +}) { + if (params.browserSessionId !== params.routerSessionId) return null; + + const targetSessionId = params.routerSessionId || params.rootSessionId; + return targetSessionId !== params.activeSessionId ? targetSessionId : null; +} + export function isValidChatHttpUrl(value?: string | null): value is string { if (!value) return false; try { diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 3f3dc26ee..affaa4377 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -237,7 +237,10 @@ import { } from "@/components/canvas/chat-panel/canvas-upload-helpers"; import { mergeClawdHistoryIntoCanvasSessions, + normalizeCanvasChatSessionId, normalizeChatMediaType, + replaceCanvasChatSessionHistory, + resolveCanvasChatSessionNavigationTarget, sanitizeChatAttachment, type PickedObjectAttachment, } from "@/components/canvas/chat-panel/canvas-chat-session"; @@ -258,6 +261,55 @@ function dbTextMessage( } test.describe("canvas chat pure helpers", () => { + test("updates the chat query without reusing Next.js internal history state", () => { + const calls: Array<{ data: unknown; url: string | URL | null }> = []; + const sessionId = "canvas-44444444-4444-4444-8444-444444444444"; + const history = { + replaceState(data: unknown, _unused: string, url?: string | URL | null) { + calls.push({ data, url: url ?? null }); + }, + } as Pick; + + expect( + replaceCanvasChatSessionHistory({ + currentUrl: "https://ai.yydd.ai/canvas/project-id?mode=agent", + history, + sessionId, + }) + ).toBe(sessionId); + expect(calls).toHaveLength(1); + expect(calls[0]?.data).toBeNull(); + + const nextUrl = new URL(String(calls[0]?.url)); + expect(nextUrl.searchParams.get("mode")).toBe("agent"); + expect(nextUrl.searchParams.get("chat")).toBe(sessionId); + expect(normalizeCanvasChatSessionId(nextUrl.searchParams.get("chat"))).toBe( + sessionId + ); + }); + + test("ignores stale router chat state until it matches the browser URL", () => { + const oldSessionId = "canvas-55555555-5555-4555-8555-555555555555"; + const newSessionId = "canvas-66666666-6666-4666-8666-666666666666"; + + expect( + resolveCanvasChatSessionNavigationTarget({ + activeSessionId: newSessionId, + browserSessionId: newSessionId, + rootSessionId: oldSessionId, + routerSessionId: oldSessionId, + }) + ).toBeNull(); + expect( + resolveCanvasChatSessionNavigationTarget({ + activeSessionId: newSessionId, + browserSessionId: oldSessionId, + rootSessionId: oldSessionId, + routerSessionId: oldSessionId, + }) + ).toBe(oldSessionId); + }); + test("rebuilds Claw session history from persisted database records", () => { const localSession = { id: "canvas-11111111-1111-4111-8111-111111111111",