From a6f302ebd115553dd2766ce442c4d4704604bf20 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Sun, 2 Aug 2026 19:53:31 +0800 Subject: [PATCH] fix(web): preserve mobile scroll intent after pointer cancellation (#1312) Keep touch and pen input eligible to cancel initial bottom settling when native panning dispatches pointercancel before scroll. Add interaction coverage for the mobile event sequence and unrelated global cancellations. --- .../HappyThread.mobile-scroll.test.tsx | 137 ++++++++++++++++++ .../components/AssistantChat/HappyThread.tsx | 35 ++++- 2 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx diff --git a/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx b/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx new file mode 100644 index 00000000..563c65a4 --- /dev/null +++ b/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx @@ -0,0 +1,137 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import type { PropsWithChildren } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' + +vi.mock('@assistant-ui/react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAuiState: (selector: (state: unknown) => unknown) => selector({ + thread: { extras: undefined } + }), + ThreadPrimitive: { + ...actual.ThreadPrimitive, + Root: ({ children, className }: PropsWithChildren<{ className?: string }>) => ( +
{children}
+ ), + Viewport: ({ children }: PropsWithChildren) => children, + Messages: () => null + } + } +}) + +import { HappyThread } from '@/components/AssistantChat/HappyThread' +import type { ApiClient } from '@/api/client' +import type { Session } from '@/types/api' + +const originalScrollTo = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTo') + +function renderThread(onViewModeChange = vi.fn()) { + const result = render( + + + + ) + const viewport = result.container.querySelector('.chat-scroll-y') + if (!viewport) { + throw new Error('Chat viewport was not rendered') + } + Object.defineProperties(viewport, { + scrollHeight: { configurable: true, value: 1_232 }, + clientHeight: { configurable: true, value: 530 } + }) + act(() => { + vi.advanceTimersByTime(0) + }) + return { ...result, viewport, onViewModeChange } +} + +beforeEach(() => { + vi.useFakeTimers() + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + writable: true, + value(this: HTMLElement, options: ScrollToOptions | number, y?: number) { + const requestedTop = typeof options === 'number' ? y ?? 0 : options.top ?? 0 + const maxScrollTop = Math.max(0, this.scrollHeight - this.clientHeight) + this.scrollTop = Math.min(Math.max(0, requestedTop), maxScrollTop) + } + }) +}) + +afterEach(() => { + cleanup() + vi.clearAllTimers() + vi.useRealTimers() + if (originalScrollTo) { + Object.defineProperty(HTMLElement.prototype, 'scrollTo', originalScrollTo) + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo') + } +}) + +describe('mobile initial scroll settling', () => { + it('does not snap back after pointer cancellation ends a touch swipe', () => { + const { viewport, onViewModeChange } = renderThread() + expect(viewport.scrollTop).toBe(702) + + const pointerDown = new Event('pointerdown', { bubbles: true }) + Object.defineProperties(pointerDown, { + button: { value: 0 }, + pointerType: { value: 'touch' } + }) + fireEvent(viewport, pointerDown) + const pointerCancel = new Event('pointercancel', { bubbles: true }) + Object.defineProperty(pointerCancel, 'pointerType', { value: 'touch' }) + fireEvent(viewport, pointerCancel) + + viewport.scrollTop = 520 + fireEvent.scroll(viewport) + act(() => { + vi.advanceTimersByTime(1_800) + }) + + expect(viewport.scrollTop).toBe(520) + expect(onViewModeChange).toHaveBeenLastCalledWith('history') + }) + + it('ignores pointer cancellation that did not start in the chat viewport', () => { + const { viewport, onViewModeChange } = renderThread() + const pointerCancel = new Event('pointercancel', { bubbles: true }) + Object.defineProperty(pointerCancel, 'pointerType', { value: 'touch' }) + fireEvent(window, pointerCancel) + + viewport.scrollTop = 520 + fireEvent.scroll(viewport) + act(() => { + vi.advanceTimersByTime(1_800) + }) + + expect(viewport.scrollTop).toBe(702) + expect(onViewModeChange).not.toHaveBeenCalledWith('history') + }) +}) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index bd8a79df..a2a1835a 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -117,6 +117,7 @@ const TOP_PULL_TRIGGER_PX = 64 // run after its bounded retry budget is exhausted. const WHEEL_GESTURE_GAP_MS = 250 const KEYBOARD_SCROLL_INTENT_WINDOW_MS = 750 +const POINTER_CANCEL_INTENT_WINDOW_MS = 750 const UPWARD_SCROLL_KEYS = new Set(['ArrowUp', 'PageUp', 'Home']) export function getPullToLoadState(distancePx: number): PullToLoadState { @@ -607,6 +608,7 @@ export function HappyThread(props: { } let pointerResumeActive = false + let pointerResumeUntil = 0 let pointerResumeLatched = false let keyboardResumeUntil = 0 let lastWheelAt = 0 @@ -616,6 +618,7 @@ export function HappyThread(props: { const hasExplicitUpwardIntent = (intent: ScrollIntent): boolean => { return intent.isScrollingUp && ( pointerResumeActive + || pointerResumeUntil >= Date.now() || keyboardResumeUntil >= Date.now() || wheelIntentUntil >= Date.now() ) @@ -625,7 +628,7 @@ export function HappyThread(props: { if (!hasExplicitUpwardIntent(intent)) { return false } - if (pointerResumeActive && !pointerResumeLatched) { + if ((pointerResumeActive || pointerResumeUntil >= Date.now()) && !pointerResumeLatched) { pointerResumeLatched = true return true } @@ -750,11 +753,27 @@ export function HappyThread(props: { return } pointerResumeActive = true + pointerResumeUntil = 0 pointerResumeLatched = false } - const handlePointerEnd = () => { + const clearPointerIntent = () => { pointerResumeActive = false + pointerResumeUntil = 0 + pointerResumeLatched = false + } + + const handlePointerCancel = (event: PointerEvent) => { + const hadActivePointer = pointerResumeActive + pointerResumeActive = false + if (hadActivePointer && (event.pointerType === 'touch' || event.pointerType === 'pen')) { + // Native panning cancels the pointer before some mobile browsers + // dispatch the resulting scroll event. Retain that explicit input + // briefly so initial bottom-settling cannot reclaim the viewport. + pointerResumeUntil = Date.now() + POINTER_CANCEL_INTENT_WINDOW_MS + return + } + pointerResumeUntil = 0 pointerResumeLatched = false } @@ -832,9 +851,9 @@ export function HappyThread(props: { viewport.addEventListener('touchmove', handleTouchMove, { passive: true }) viewport.addEventListener('touchend', handleTouchEnd, { passive: true }) viewport.addEventListener('touchcancel', handleTouchCancel, { passive: true }) - window.addEventListener('pointerup', handlePointerEnd, { passive: true }) - window.addEventListener('pointercancel', handlePointerEnd, { passive: true }) - window.addEventListener('blur', handlePointerEnd) + window.addEventListener('pointerup', clearPointerIntent, { passive: true }) + window.addEventListener('pointercancel', handlePointerCancel, { passive: true }) + window.addEventListener('blur', clearPointerIntent) return () => { viewport.removeEventListener('scroll', handleScroll) viewport.removeEventListener('keydown', handleKeyDown) @@ -844,9 +863,9 @@ export function HappyThread(props: { viewport.removeEventListener('touchmove', handleTouchMove) viewport.removeEventListener('touchend', handleTouchEnd) viewport.removeEventListener('touchcancel', handleTouchCancel) - window.removeEventListener('pointerup', handlePointerEnd) - window.removeEventListener('pointercancel', handlePointerEnd) - window.removeEventListener('blur', handlePointerEnd) + window.removeEventListener('pointerup', clearPointerIntent) + window.removeEventListener('pointercancel', handlePointerCancel) + window.removeEventListener('blur', clearPointerIntent) } }, []) // Stable: no dependencies, reads from refs