From aa9997b75681f5ac8bdd279cd39677f9babe23a1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 14 Jul 2026 20:42:33 +0100 Subject: [PATCH] fix: support touch history loading --- ui/src/pages/chat/chat-pane.test.ts | 24 ++++++++++++++++++ ui/src/pages/chat/chat-pane.ts | 23 +++++++++++++++++- ui/src/pages/chat/chat-view.test.ts | 27 +++++++++++++++++++++ ui/src/pages/chat/components/chat-thread.ts | 10 +++++++- 4 files changed, 82 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 7931109dd8c9..7b5765fe7cd1 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -695,6 +695,30 @@ describe("chat pane catalog session lifecycle", () => { expect(pane.loadOlderMessages).toHaveBeenCalledOnce(); expect(pane.historyAutoLoadBlocked).toBe(false); }); + + it("loads a blocked unscrollable transcript from a downward touch pull", async () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); + pane.historyAutoLoadBlocked = true; + pane.hasOlderMessages = vi.fn(() => true); + pane.loadOlderMessages = vi.fn(async () => undefined); + vi.stubGlobal("IntersectionObserver", undefined); + const thread = document.createElement("div"); + const touchEvent = (type: string, clientY: number) => { + const event = new TouchEvent(type); + Object.defineProperty(event, "currentTarget", { value: thread }); + Object.defineProperty(event, "touches", { value: [{ clientY }] }); + return event; + }; + + pane.handleTranscriptHistoryIntent(touchEvent("touchstart", 100)); + pane.handleTranscriptHistoryIntent(touchEvent("touchmove", 106)); + pane.handleTranscriptHistoryIntent(touchEvent("touchmove", 112)); + await Promise.resolve(); + + expect(pane.loadOlderMessages).toHaveBeenCalledOnce(); + expect(pane.historyAutoLoadBlocked).toBe(false); + }); }); describe("chat pane native history pagination", () => { diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 871727809061..7a8a1ecca50c 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -153,6 +153,7 @@ type ChatHistoryAnchor = { const CATALOG_TOOL_RESULT_PREVIEW_MAX_CHARS = 500; const CHAT_HISTORY_INTENT_EDGE_PX = 300; const CHAT_HISTORY_INTENT_IDLE_MS = 200; +const CHAT_HISTORY_TOUCH_INTENT_PX = 8; const CHAT_HISTORY_UPWARD_KEYS = new Set(["ArrowUp", "PageUp", "Home"]); function catalogRawString(raw: unknown, keys: readonly string[]): string | null { @@ -314,6 +315,7 @@ class ChatPane extends OpenClawLightDomElement { private historyBootstrapPagesLoaded = 0; private historyIntentConsumed = false; private historyIntentTimer: number | null = null; + private historyTouchY: number | null = null; private transcriptScrollTop: number | null = null; private pendingHistoryAnchor: ChatHistoryAnchor | null = null; private nativePaginationSnapshot: ChatHistoryPagination | null = null; @@ -939,6 +941,7 @@ class ChatPane extends OpenClawLightDomElement { this.historyAutoLoadBlocked = false; this.historyBootstrapPagesLoaded = 0; this.historyIntentConsumed = false; + this.historyTouchY = null; if (this.historyIntentTimer !== null) { window.clearTimeout(this.historyIntentTimer); this.historyIntentTimer = null; @@ -1104,9 +1107,27 @@ class ChatPane extends OpenClawLightDomElement { private handleTranscriptHistoryIntent(event: Event): void { const root = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; - const upward = + let upward = (event instanceof WheelEvent && event.deltaY < 0) || (event instanceof KeyboardEvent && CHAT_HISTORY_UPWARD_KEYS.has(event.key)); + if (event instanceof TouchEvent) { + const touchY = event.touches[0]?.clientY ?? null; + if (event.type === "touchstart") { + this.historyTouchY = touchY; + return; + } + if (event.type === "touchend" || event.type === "touchcancel") { + this.historyTouchY = null; + return; + } + const previousTouchY = this.historyTouchY; + if (touchY !== null && previousTouchY !== null) { + upward = touchY - previousTouchY >= CHAT_HISTORY_TOUCH_INTENT_PX; + if (upward || touchY < previousTouchY) { + this.historyTouchY = touchY; + } + } + } if ( !root || !upward || diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 1b5d8ea51520..5f3db53f14a1 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -725,6 +725,33 @@ describe("chat history pagination", () => { thread.dispatchEvent(new KeyboardEvent("keydown", { key: "PageUp", bubbles: true })); expect(onHistoryIntent).toHaveBeenCalledTimes(2); }); + + it("keeps wheel and touch history intent listeners passive", () => { + const addEventListener = vi.spyOn(EventTarget.prototype, "addEventListener"); + try { + renderChatView({ + historyPagination: { + loading: false, + }, + onHistoryIntent: vi.fn(), + }); + + for (const eventName of ["wheel", "touchstart", "touchmove"]) { + expect( + addEventListener.mock.calls.some( + ([type, , options]) => + type === eventName && + typeof options === "object" && + options !== null && + "passive" in options && + options.passive === true, + ), + ).toBe(true); + } + } finally { + addEventListener.mockRestore(); + } + }); }); describe("direct thread avatar mode", () => { diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index 808c4f9a7441..b03f3e2fe9ef 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -653,8 +653,16 @@ export function renderChatThread(props: ChatThreadProps) { aria-live="polite" tabindex="0" @scroll=${props.onChatScroll} - @wheel=${props.onHistoryIntent} + @wheel=${props.onHistoryIntent ? { handleEvent: props.onHistoryIntent, passive: true } : null} @keydown=${props.onHistoryIntent} + @touchstart=${props.onHistoryIntent + ? { handleEvent: props.onHistoryIntent, passive: true } + : null} + @touchmove=${props.onHistoryIntent + ? { handleEvent: props.onHistoryIntent, passive: true } + : null} + @touchend=${props.onHistoryIntent} + @touchcancel=${props.onHistoryIntent} @mousedown=${beginNativeWindowDragFromTopInset} @click=${(event: Event) => { handleMarkdownCodeBlockCopy(event);