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
17 type DragEvent,17 type DragEvent,
18 type FormEvent,18 type FormEvent,
19 type KeyboardEvent,19 type KeyboardEvent,
20 type PointerEvent,
21 type TouchEvent,
20 type WheelEvent,22 type WheelEvent,
21 memo,23 memo,
22 type RefObject,24 type RefObject,
@@ -404,7 +406,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 381 unmodified lines
404 const messagesScrollRef = useRef<HTMLDivElement>(null);406 const messagesScrollRef = useRef<HTMLDivElement>(null);
405 const shouldAutoScrollRef = useRef(true);407 const shouldAutoScrollRef = useRef(true);
406 const lastKnownScrollTopRef = useRef(0);408 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);
408 const pendingAutoScrollFrameRef = useRef<number | null>(null);412 const pendingAutoScrollFrameRef = useRef<number | null>(null);
409 const textareaRef = useRef<HTMLTextAreaElement>(null);413 const textareaRef = useRef<HTMLTextAreaElement>(null);
410 const composerCommandInputRef = useRef<HTMLInputElement>(null);414 const composerCommandInputRef = useRef<HTMLInputElement>(null);
@@ -1105,7 +1109,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 694 unmodified lines
1105 if (!scrollContainer) return;1109 if (!scrollContainer) return;
1106 scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });1110 scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
1107 lastKnownScrollTopRef.current = scrollContainer.scrollTop;1111 lastKnownScrollTopRef.current = scrollContainer.scrollTop;
1108 lastKnownScrollHeightRef.current = scrollContainer.scrollHeight;
1109 shouldAutoScrollRef.current = true;1112 shouldAutoScrollRef.current = true;
1110 }, []);1113 }, []);
1111 const requestAutoTailToBottom = useCallback(() => {1114 const requestAutoTailToBottom = useCallback(() => {
@@ -1120,28 +1123,59 @@ export default function ChatView({ threadId }: ChatViewProps) { · 8 unmodified lines
1120 const scrollContainer = messagesScrollRef.current;1123 const scrollContainer = messagesScrollRef.current;
1121 if (!scrollContainer) return;1124 if (!scrollContainer) return;
1122 const currentScrollTop = scrollContainer.scrollTop;1125 const currentScrollTop = scrollContainer.scrollTop;
1123 const currentScrollHeight = scrollContainer.scrollHeight;
1124 const atBottom = isScrollContainerAtBottom(scrollContainer);1126 const atBottom = isScrollContainerAtBottom(scrollContainer);
1125 1127
1126 if (atBottom) {1128 if (!shouldAutoScrollRef.current && atBottom) {
1127 shouldAutoScrollRef.current = true;1129 shouldAutoScrollRef.current = true;
1128 } else if (shouldAutoScrollRef.current) {1130 pendingUserScrollUpIntentRef.current = false;
1129 // While tailing, only relinquish control when the user scrolls upward.1131 } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) {
1130 const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;1132 const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
1131 const contentHeightChanged =1133 if (scrolledUp) {
1132 Math.abs(currentScrollHeight - lastKnownScrollHeightRef.current) > 1;1134 shouldAutoScrollRef.current = false;
1133 if (scrolledUp && !contentHeightChanged) {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) {
1134 shouldAutoScrollRef.current = false;1141 shouldAutoScrollRef.current = false;
1135 }1142 }
1136 }1143 }
1137 1144
1138 lastKnownScrollTopRef.current = currentScrollTop;1145 lastKnownScrollTopRef.current = currentScrollTop;
1139 lastKnownScrollHeightRef.current = currentScrollHeight;
1140 }, []);1146 }, []);
1141 const onMessagesWheel = useCallback((event: WheelEvent<HTMLDivElement>) => {1147 const onMessagesWheel = useCallback((event: WheelEvent<HTMLDivElement>) => {
1148 // Wheel-up indicates user intent to move away from bottom.
1142 if (event.deltaY < 0) {1149 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;
1144 }1174 }
1175 lastTouchClientYRef.current = touch.clientY;
1176 }, []);
1177 const onMessagesTouchEnd = useCallback((_event: TouchEvent<HTMLDivElement>) => {
1178 lastTouchClientYRef.current = null;
1145 }, []);1179 }, []);
1146 useLayoutEffect(() => {1180 useLayoutEffect(() => {
1147 if (!activeThread?.id) return;1181 if (!activeThread?.id) return;
@@ -2089,6 +2123,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 941 unmodified lines
2089 className="min-h-0 flex-1 overflow-y-auto px-5 py-4"2123 className="min-h-0 flex-1 overflow-y-auto px-5 py-4"
2090 onScroll={onMessagesScroll}2124 onScroll={onMessagesScroll}
2091 onWheel={onMessagesWheel}2125 onWheel={onMessagesWheel}
2126 onPointerDown={onMessagesPointerDown}
2127 onPointerUp={onMessagesPointerUp}
2128 onPointerCancel={onMessagesPointerCancel}
2129 onTouchStart={onMessagesTouchStart}
2130 onTouchMove={onMessagesTouchMove}
2131 onTouchEnd={onMessagesTouchEnd}
2132 onTouchCancel={onMessagesTouchEnd}
2092 >2133 >
2093 <MessagesTimeline2134 <MessagesTimeline
2094 hasMessages={activeThread.messages.length > 0}2135 hasMessages={activeThread.messages.length > 0}