diff --git a/apps/web/src/chat-scroll.test.ts b/apps/web/src/chat-scroll.test.ts index 5311fb40..e8a70fb9 100644 --- a/apps/web/src/chat-scroll.test.ts +++ b/apps/web/src/chat-scroll.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "./chat-scroll"; +import { + AUTO_SCROLL_BOTTOM_THRESHOLD_PX, + computeNextAutoScrollState, + isScrollContainerNearBottom, + type AutoScrollStateInputs, +} from "./chat-scroll"; describe("isScrollContainerNearBottom", () => { it("returns true when already at bottom", () => { @@ -59,4 +64,170 @@ describe("isScrollContainerNearBottom", () => { ).toBe(true); expect(AUTO_SCROLL_BOTTOM_THRESHOLD_PX).toBe(64); }); + + it("returns true when scrollTop overshoots scrollHeight (rubber-band)", () => { + expect( + isScrollContainerNearBottom({ + scrollTop: 700, + clientHeight: 400, + scrollHeight: 1_000, + }), + ).toBe(true); + }); + + it("returns true at the exact boundary of the threshold", () => { + // distanceFromBottom = 1000 - 400 - 536 = 64 (== threshold) + expect( + isScrollContainerNearBottom({ + scrollTop: 536, + clientHeight: 400, + scrollHeight: 1_000, + }), + ).toBe(true); + }); + + it("returns true when the container has zero size (degenerate)", () => { + expect( + isScrollContainerNearBottom({ + scrollTop: 0, + clientHeight: 0, + scrollHeight: 0, + }), + ).toBe(true); + }); +}); + +describe("computeNextAutoScrollState", () => { + const baseInputs: AutoScrollStateInputs = { + shouldAutoScroll: true, + pendingUserScrollUpIntent: false, + isPointerScrollActive: false, + isNearBottom: true, + currentScrollTop: 600, + lastKnownScrollTop: 600, + }; + + it("re-engages auto-scroll when the user returns to the bottom", () => { + // Issue #13 scenario: user scrolled up, then submits a message — + // the submit handler scrolls to bottom, the resulting scroll event + // should re-enable auto-scroll. + const result = computeNextAutoScrollState({ + ...baseInputs, + shouldAutoScroll: false, + pendingUserScrollUpIntent: true, + isNearBottom: true, + currentScrollTop: 600, + lastKnownScrollTop: 200, + }); + expect(result.shouldAutoScroll).toBe(true); + expect(result.pendingUserScrollUpIntent).toBe(false); + }); + + it("keeps auto-scroll on for a streaming response while user is at bottom", () => { + // Issue #13 scenario: streaming response starts; user has not scrolled up. + const result = computeNextAutoScrollState({ + ...baseInputs, + currentScrollTop: 612, + lastKnownScrollTop: 600, + }); + expect(result.shouldAutoScroll).toBe(true); + }); + + it("keeps auto-scroll on across rapid optimistic submits at the bottom", () => { + // Issue #13 scenario: multiple rapid submissions — each submit triggers + // a scroll-to-bottom; consecutive scroll events at the bottom must not + // flip the flag off. + let state: AutoScrollStateInputs = { ...baseInputs }; + for (let i = 0; i < 5; i += 1) { + const next = computeNextAutoScrollState(state); + expect(next.shouldAutoScroll).toBe(true); + state = { + ...state, + shouldAutoScroll: next.shouldAutoScroll, + pendingUserScrollUpIntent: next.pendingUserScrollUpIntent, + lastKnownScrollTop: state.currentScrollTop, + currentScrollTop: state.currentScrollTop + 24, + }; + } + }); + + it("disables auto-scroll when a wheel-flagged scroll moves up beyond tolerance", () => { + const result = computeNextAutoScrollState({ + ...baseInputs, + pendingUserScrollUpIntent: true, + isNearBottom: false, + currentScrollTop: 540, + lastKnownScrollTop: 600, + }); + expect(result.shouldAutoScroll).toBe(false); + expect(result.pendingUserScrollUpIntent).toBe(false); + }); + + it("clears the wheel intent flag even if the position did not actually move up", () => { + // The intent has been "consumed" by the scroll event regardless of outcome, + // matching the inline implementation that always cleared the flag here. + const result = computeNextAutoScrollState({ + ...baseInputs, + pendingUserScrollUpIntent: true, + currentScrollTop: 600, + lastKnownScrollTop: 600, + }); + expect(result.shouldAutoScroll).toBe(true); + expect(result.pendingUserScrollUpIntent).toBe(false); + }); + + it("disables auto-scroll on a real upward delta during a pointer drag", () => { + const result = computeNextAutoScrollState({ + ...baseInputs, + isPointerScrollActive: true, + isNearBottom: false, + currentScrollTop: 500, + lastKnownScrollTop: 600, + }); + expect(result.shouldAutoScroll).toBe(false); + }); + + it("ignores sub-pixel jitter during a pointer drag", () => { + const result = computeNextAutoScrollState({ + ...baseInputs, + isPointerScrollActive: true, + currentScrollTop: 599.5, + lastKnownScrollTop: 600, + }); + expect(result.shouldAutoScroll).toBe(true); + }); + + it("disables auto-scroll on keyboard scroll-up away from the bottom", () => { + // Catch-all branch: no pointer, no wheel intent, just an upward scroll + // (e.g. PageUp / arrow keys / assistive tech). + const result = computeNextAutoScrollState({ + ...baseInputs, + isNearBottom: false, + currentScrollTop: 400, + lastKnownScrollTop: 600, + }); + expect(result.shouldAutoScroll).toBe(false); + }); + + it("does not disable auto-scroll on a downward keyboard scroll", () => { + const result = computeNextAutoScrollState({ + ...baseInputs, + isNearBottom: false, + currentScrollTop: 650, + lastKnownScrollTop: 600, + }); + expect(result.shouldAutoScroll).toBe(true); + }); + + it("is a no-op when auto-scroll is already off and user is still above the bottom", () => { + const result = computeNextAutoScrollState({ + ...baseInputs, + shouldAutoScroll: false, + isNearBottom: false, + currentScrollTop: 200, + lastKnownScrollTop: 200, + }); + expect(result.shouldAutoScroll).toBe(false); + expect(result.pendingUserScrollUpIntent).toBe(false); + }); }); diff --git a/apps/web/src/chat-scroll.ts b/apps/web/src/chat-scroll.ts index 35190ab1..a4a9833e 100644 --- a/apps/web/src/chat-scroll.ts +++ b/apps/web/src/chat-scroll.ts @@ -22,3 +22,95 @@ export function isScrollContainerNearBottom( const distanceFromBottom = scrollHeight - clientHeight - scrollTop; return distanceFromBottom <= threshold; } + +/** + * Minimum pixel delta required to interpret a scroll position change as an + * intentional upward scroll. Sub-pixel jitter from the browser, virtualizer, + * or layout shifts should not flip the auto-scroll state. + */ +export const SCROLL_UP_DETECTION_TOLERANCE_PX = 1; + +export interface AutoScrollStateInputs { + /** Previous value of the auto-scroll intent flag. */ + shouldAutoScroll: boolean; + /** Whether a wheel/touch gesture has flagged a likely upward scroll. */ + pendingUserScrollUpIntent: boolean; + /** Whether a pointer/touch press is currently driving the scroll. */ + isPointerScrollActive: boolean; + /** Whether the container is currently within the auto-scroll threshold. */ + isNearBottom: boolean; + /** Current scrollTop reported by the scroll event. */ + currentScrollTop: number; + /** scrollTop captured from the previous scroll event. */ + lastKnownScrollTop: number; +} + +export interface AutoScrollStateResult { + /** Next value of the auto-scroll intent flag. */ + shouldAutoScroll: boolean; + /** Next value of the pending user scroll-up intent flag. */ + pendingUserScrollUpIntent: boolean; +} + +/** + * Pure state-machine for the chat view's auto-scroll behavior. + * + * Mirrors the inline branches of `ChatView.onMessagesScroll` so the rules + * around "should we still stick to the bottom?" can be exercised in unit + * tests without rendering the full chat tree. Behavior is intentionally + * identical to the previous inline implementation. + * + * Rules, in priority order: + * 1. If auto-scroll was off but the user scrolled back to the bottom, turn + * it back on and clear any pending up-intent. + * 2. If a wheel/touch gesture flagged an up-intent, only disable auto-scroll + * when the position actually moved up beyond the tolerance, then clear + * the intent flag (it has been consumed). + * 3. If a pointer/touch press is driving the scroll, disable auto-scroll + * only on a real upward delta. + * 4. Catch-all for keyboard / assistive scrolls: if we are no longer near + * the bottom and the position moved up beyond the tolerance, disable + * auto-scroll. + */ +export function computeNextAutoScrollState( + inputs: AutoScrollStateInputs, +): AutoScrollStateResult { + const { + shouldAutoScroll, + pendingUserScrollUpIntent, + isPointerScrollActive, + isNearBottom, + currentScrollTop, + lastKnownScrollTop, + } = inputs; + + const scrolledUp = + currentScrollTop < lastKnownScrollTop - SCROLL_UP_DETECTION_TOLERANCE_PX; + + if (!shouldAutoScroll && isNearBottom) { + return { shouldAutoScroll: true, pendingUserScrollUpIntent: false }; + } + + if (shouldAutoScroll && pendingUserScrollUpIntent) { + return { + shouldAutoScroll: scrolledUp ? false : shouldAutoScroll, + pendingUserScrollUpIntent: false, + }; + } + + if (shouldAutoScroll && isPointerScrollActive) { + return { + shouldAutoScroll: scrolledUp ? false : shouldAutoScroll, + pendingUserScrollUpIntent, + }; + } + + if (shouldAutoScroll && !isNearBottom) { + return { + shouldAutoScroll: scrolledUp ? false : shouldAutoScroll, + pendingUserScrollUpIntent, + }; + } + + return { shouldAutoScroll, pendingUserScrollUpIntent }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 55585a81..eab88a13 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -76,7 +76,7 @@ import { isLatestTurnSettled, formatElapsed, } from "../session-logic"; -import { isScrollContainerNearBottom } from "../chat-scroll"; +import { computeNextAutoScrollState, isScrollContainerNearBottom } from "../chat-scroll"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -2217,27 +2217,16 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { const currentScrollTop = scrollContainer.scrollTop; const isNearBottom = isScrollContainerNearBottom(scrollContainer); - if (!shouldAutoScrollRef.current && isNearBottom) { - shouldAutoScrollRef.current = true; - pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } - pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } - } else if (shouldAutoScrollRef.current && !isNearBottom) { - // Catch-all for keyboard/assistive scroll interactions. - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } - } + const next = computeNextAutoScrollState({ + shouldAutoScroll: shouldAutoScrollRef.current, + pendingUserScrollUpIntent: pendingUserScrollUpIntentRef.current, + isPointerScrollActive: isPointerScrollActiveRef.current, + isNearBottom, + currentScrollTop, + lastKnownScrollTop: lastKnownScrollTopRef.current, + }); + shouldAutoScrollRef.current = next.shouldAutoScroll; + pendingUserScrollUpIntentRef.current = next.pendingUserScrollUpIntent; setShowScrollToBottom(!shouldAutoScrollRef.current); lastKnownScrollTopRef.current = currentScrollTop;