Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 172 additions & 1 deletion apps/web/src/chat-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
92 changes: 92 additions & 0 deletions apps/web/src/chat-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
33 changes: 11 additions & 22 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ import {
isLatestTurnSettled,
formatElapsed,
} from "../session-logic";
import { isScrollContainerNearBottom } from "../chat-scroll";
import { computeNextAutoScrollState, isScrollContainerNearBottom } from "../chat-scroll";
import {
buildPendingUserInputAnswers,
derivePendingUserInputProgress,
Expand Down Expand Up @@ -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;
Expand Down