From a2f6d96bfb246568ca7015534b3ef1baba9fe567 Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 15 Jul 2026 01:43:35 +0100 Subject: [PATCH] fix: harden chat history lifecycle edges --- ui/src/pages/chat/chat-pane.test.ts | 22 ++++++++++++ ui/src/pages/chat/chat-pane.ts | 3 +- .../pages/chat/session-message-cache.test.ts | 36 +++++++++++++++++++ ui/src/pages/chat/session-message-cache.ts | 10 +++++- 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 8104405fd913..244e0018dcb0 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -732,6 +732,7 @@ describe("chat pane catalog session lifecycle", () => { pane.hasOlderMessages = vi.fn(() => true); pane.loadOlderMessages = vi.fn(async () => undefined); vi.stubGlobal("IntersectionObserver", undefined); + vi.stubGlobal("TouchEvent", undefined); const thread = document.createElement("div"); const event = new WheelEvent("wheel", { deltaY: -1 }); Object.defineProperty(event, "currentTarget", { value: thread }); @@ -825,6 +826,27 @@ describe("chat pane native history pagination", () => { } }); + it("does not consume bootstrap history while disconnected", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.connected = false; + state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; + const construct = vi.fn(); + class FakeIntersectionObserver { + constructor() { + construct(); + } + disconnect() {} + observe() {} + } + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + + pane.syncHistoryObserver(); + + expect(construct).not.toHaveBeenCalled(); + expect(pane.historyAutoLoadBlocked).toBe(false); + }); + it("stops non-scrollable bootstrap after one older page", async () => { const request = vi.fn(async () => ({ messages: [nativeHistoryMessage(3), nativeHistoryMessage(4)], diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index fd3576baab71..8416b6b16750 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -979,6 +979,7 @@ class ChatPane extends OpenClawLightDomElement { } if ( typeof IntersectionObserver !== "function" || + !this.state?.connected || this.loadingOlder || !this.hasOlderMessages() ) { @@ -1089,7 +1090,7 @@ class ChatPane extends OpenClawLightDomElement { let upward = (event instanceof WheelEvent && event.deltaY < 0) || (event instanceof KeyboardEvent && CHAT_HISTORY_UPWARD_KEYS.has(event.key)); - if (event instanceof TouchEvent) { + if (typeof TouchEvent !== "undefined" && event instanceof TouchEvent) { const touchY = event.touches[0]?.clientY ?? null; if (event.type === "touchstart") { this.historyTouchY = touchY; diff --git a/ui/src/pages/chat/session-message-cache.test.ts b/ui/src/pages/chat/session-message-cache.test.ts index eaec1e117748..816527c834b2 100644 --- a/ui/src/pages/chat/session-message-cache.test.ts +++ b/ui/src/pages/chat/session-message-cache.test.ts @@ -148,6 +148,42 @@ describe("session message cache", () => { expect(snapshot?.pagination).toEqual({ hasMore: false, totalMessages: 140 }); }); + it("keeps the newer same-depth snapshot when a stale pane saves later", () => { + const host = createHost(); + const cache: ChatMessageCache = new Map(); + const current = [1, 2, 3].map((seq) => ({ + content: `current-${seq}`, + __openclaw: { seq }, + })); + cacheChatSessionSnapshot( + cache, + host, + { sessionKey: "home" }, + { + messages: current, + pagination: { hasMore: false, totalMessages: 3 }, + sessionId: "session-1", + }, + ); + + cacheChatSessionSnapshot( + cache, + host, + { sessionKey: "home" }, + { + messages: current.slice(0, 2), + pagination: { hasMore: true, nextOffset: 2, totalMessages: 3 }, + sessionId: "session-1", + }, + ); + + expect(readChatSessionSnapshot(cache, host, { sessionKey: "home" })).toEqual({ + messages: current, + pagination: { hasMore: false, totalMessages: 3 }, + sessionId: "session-1", + }); + }); + it("does not retain history across backing session changes", () => { const host = createHost(); const cache: ChatMessageCache = new Map(); diff --git a/ui/src/pages/chat/session-message-cache.ts b/ui/src/pages/chat/session-message-cache.ts index 688017635837..7d68c1169067 100644 --- a/ui/src/pages/chat/session-message-cache.ts +++ b/ui/src/pages/chat/session-message-cache.ts @@ -205,6 +205,15 @@ function mergeRetainedSessionDepth( const incomingBounds = transcriptSequenceBounds(incoming.messages); const existingTotal = existing.pagination.totalMessages; const incomingTotal = incoming.pagination.totalMessages; + if ( + existingBounds && + incomingBounds && + typeof existingTotal === "number" && + incomingTotal === existingTotal && + incomingBounds.newest < existingBounds.newest + ) { + return existing; + } if ( !existingBounds || !incomingBounds || @@ -212,7 +221,6 @@ function mergeRetainedSessionDepth( typeof incomingTotal !== "number" || incomingTotal < existingTotal || incomingBounds.oldest <= existingBounds.oldest || - incomingBounds.newest < existingBounds.newest || incomingBounds.oldest > existingBounds.newest + 1 ) { return incoming;