diff --git a/web/src/components/AssistantChat/HappyThread.test.tsx b/web/src/components/AssistantChat/HappyThread.test.tsx index 3a36560d..b5d12de7 100644 --- a/web/src/components/AssistantChat/HappyThread.test.tsx +++ b/web/src/components/AssistantChat/HappyThread.test.tsx @@ -200,7 +200,18 @@ describe('scroll anchor helpers', () => { distanceFromBottom: 182, isScrollingUp: true }) - expect(shouldCancelInitialScrollSettling(intent)).toBe(true) + expect(shouldCancelInitialScrollSettling(intent, true)).toBe(true) + }) + + it('keeps initial scroll settling for programmatic upward movement', () => { + const intent = getScrollIntent({ + scrollTop: 0, + previousScrollTop: 700, + scrollHeight: 1232, + clientHeight: 530 + }) + + expect(shouldCancelInitialScrollSettling(intent, false)).toBe(false) }) it('keeps initial scroll settling for negligible movement at the bottom', () => { @@ -215,7 +226,7 @@ describe('scroll anchor helpers', () => { distanceFromBottom: 0, isScrollingUp: false }) - expect(shouldCancelInitialScrollSettling(intent)).toBe(false) + expect(shouldCancelInitialScrollSettling(intent, false)).toBe(false) }) it('restores the captured message to the same viewport offset', () => { diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index b7c4aa42..bd8a79df 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -158,8 +158,13 @@ export function getScrollIntent(params: { } } -export function shouldCancelInitialScrollSettling(intent: ScrollIntent): boolean { - return intent.isScrollingUp && intent.distanceFromBottom > MANUAL_SCROLL_EPSILON_PX +export function shouldCancelInitialScrollSettling( + intent: ScrollIntent, + hasExplicitUpwardIntent: boolean +): boolean { + return hasExplicitUpwardIntent + && intent.isScrollingUp + && intent.distanceFromBottom > MANUAL_SCROLL_EPSILON_PX } export function captureScrollAnchor(viewport: HTMLElement): ScrollAnchor | null { @@ -608,8 +613,16 @@ export function HappyThread(props: { let wheelIntentUntil = 0 let wheelLatched = false + const hasExplicitUpwardIntent = (intent: ScrollIntent): boolean => { + return intent.isScrollingUp && ( + pointerResumeActive + || keyboardResumeUntil >= Date.now() + || wheelIntentUntil >= Date.now() + ) + } + const consumeExplicitUpwardIntent = (intent: ScrollIntent): boolean => { - if (!intent.isScrollingUp) { + if (!hasExplicitUpwardIntent(intent)) { return false } if (pointerResumeActive && !pointerResumeLatched) { @@ -658,10 +671,11 @@ export function HappyThread(props: { // Keep the keyboard/pointer intent armed while the user moves // through ordinary history. Consume it only when the viewport // actually reaches the preload area. + const hadExplicitUpwardIntent = hasExplicitUpwardIntent(intent) const explicitUpwardIntent = needsCoverage && consumeExplicitUpwardIntent(intent) if (isInitialScrollSettling()) { - if (shouldCancelInitialScrollSettling(intent)) { + if (shouldCancelInitialScrollSettling(intent, hadExplicitUpwardIntent)) { initialScrollDeadlineRef.current = 0 clearInitialScrollTimers() setAutoScrollMode(false)