History

Respect user scroll intent across wheel, pointer, and touch

- track wheel-up, pointer drag, and touch gestures before disabling auto-tail - avoid false auto-scroll handoff caused by content height changes - add pointer/touch handlers to message list for consistent scroll behavior

Julius Marminge committed 8 months ago

Commit 3031dfd1 parent 0cb4e751 file · +52 −11Browse files

Unified · Split

apps/web/src/components/ChatView.tsx+52−11
@@ -17,6 +17,8 @@ import { · 16 unmodified lines
1717 type DragEvent,
1818 type FormEvent,
1919 type KeyboardEvent,
20+ type PointerEvent,
21+ type TouchEvent,
2022 type WheelEvent,
2123 memo,
2224 type RefObject,
@@ -404,7 +406,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 381 unmodified lines
404406 const messagesScrollRef = useRef<HTMLDivElement>(null);
405407 const shouldAutoScrollRef = useRef(true);
406408 const lastKnownScrollTopRef = useRef(0);
407− const lastKnownScrollHeightRef = useRef(0);
409+ const isPointerScrollActiveRef = useRef(false);
410+ const lastTouchClientYRef = useRef<number | null>(null);
411+ const pendingUserScrollUpIntentRef = useRef(false);
408412 const pendingAutoScrollFrameRef = useRef<number | null>(null);
409413 const textareaRef = useRef<HTMLTextAreaElement>(null);
410414 const composerCommandInputRef = useRef<HTMLInputElement>(null);
@@ -1105,7 +1109,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 694 unmodified lines
11051109 if (!scrollContainer) return;
11061110 scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
11071111 lastKnownScrollTopRef.current = scrollContainer.scrollTop;
1108− lastKnownScrollHeightRef.current = scrollContainer.scrollHeight;
11091112 shouldAutoScrollRef.current = true;
11101113 }, []);
11111114 const requestAutoTailToBottom = useCallback(() => {
@@ -1120,28 +1123,59 @@ export default function ChatView({ threadId }: ChatViewProps) { · 8 unmodified lines
11201123 const scrollContainer = messagesScrollRef.current;
11211124 if (!scrollContainer) return;
11221125 const currentScrollTop = scrollContainer.scrollTop;
1123− const currentScrollHeight = scrollContainer.scrollHeight;
11241126 const atBottom = isScrollContainerAtBottom(scrollContainer);
11251127
1126− if (atBottom) {
1128+ if (!shouldAutoScrollRef.current && atBottom) {
11271129 shouldAutoScrollRef.current = true;
1128− } else if (shouldAutoScrollRef.current) {
1129− // While tailing, only relinquish control when the user scrolls upward.
1130+ pendingUserScrollUpIntentRef.current = false;
1131+ } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) {
11301132 const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
1131− const contentHeightChanged =
1132− Math.abs(currentScrollHeight - lastKnownScrollHeightRef.current) > 1;
1133− if (scrolledUp && !contentHeightChanged) {
1133+ if (scrolledUp) {
1134+ shouldAutoScrollRef.current = false;
1135+ }
1136+ pendingUserScrollUpIntentRef.current = false;
1137+ } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) {
1138+ // Pointer-driven upward scroll means user takes control.
1139+ const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
1140+ if (scrolledUp) {
11341141 shouldAutoScrollRef.current = false;
11351142 }
11361143 }
11371144
11381145 lastKnownScrollTopRef.current = currentScrollTop;
1139− lastKnownScrollHeightRef.current = currentScrollHeight;
11401146 }, []);
11411147 const onMessagesWheel = useCallback((event: WheelEvent<HTMLDivElement>) => {
1148+ // Wheel-up indicates user intent to move away from bottom.
11421149 if (event.deltaY < 0) {
1143− shouldAutoScrollRef.current = false;
1150+ pendingUserScrollUpIntentRef.current = true;
1151+ }
1152+ }, []);
1153+ const onMessagesPointerDown = useCallback((_event: PointerEvent<HTMLDivElement>) => {
1154+ isPointerScrollActiveRef.current = true;
1155+ }, []);
1156+ const onMessagesPointerUp = useCallback((_event: PointerEvent<HTMLDivElement>) => {
1157+ isPointerScrollActiveRef.current = false;
1158+ }, []);
1159+ const onMessagesPointerCancel = useCallback((_event: PointerEvent<HTMLDivElement>) => {
1160+ isPointerScrollActiveRef.current = false;
1161+ }, []);
1162+ const onMessagesTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => {
1163+ const touch = event.touches[0];
1164+ if (!touch) return;
1165+ lastTouchClientYRef.current = touch.clientY;
1166+ }, []);
1167+ const onMessagesTouchMove = useCallback((event: TouchEvent<HTMLDivElement>) => {
1168+ const touch = event.touches[0];
1169+ if (!touch) return;
1170+ const previousTouchY = lastTouchClientYRef.current;
1171+ if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
1172+ // Finger moved downward => user intends to scroll content upward.
1173+ pendingUserScrollUpIntentRef.current = true;
11441174 }
1175+ lastTouchClientYRef.current = touch.clientY;
1176+ }, []);
1177+ const onMessagesTouchEnd = useCallback((_event: TouchEvent<HTMLDivElement>) => {
1178+ lastTouchClientYRef.current = null;
11451179 }, []);
11461180 useLayoutEffect(() => {
11471181 if (!activeThread?.id) return;
@@ -2089,6 +2123,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 941 unmodified lines
20892123 className="min-h-0 flex-1 overflow-y-auto px-5 py-4"
20902124 onScroll={onMessagesScroll}
20912125 onWheel={onMessagesWheel}
2126+ onPointerDown={onMessagesPointerDown}
2127+ onPointerUp={onMessagesPointerUp}
2128+ onPointerCancel={onMessagesPointerCancel}
2129+ onTouchStart={onMessagesTouchStart}
2130+ onTouchMove={onMessagesTouchMove}
2131+ onTouchEnd={onMessagesTouchEnd}
2132+ onTouchCancel={onMessagesTouchEnd}
20922133 >
20932134 <MessagesTimeline
20942135 hasMessages={activeThread.messages.length > 0}