- 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
Unified · Split
| @@ -17,6 +17,8 @@ import { · 16 unmodified lines | ||
| 17 | 17 | type DragEvent, |
| 18 | 18 | type FormEvent, |
| 19 | 19 | type KeyboardEvent, |
| 20 | + type PointerEvent, | |
| 21 | + type TouchEvent, | |
| 20 | 22 | type WheelEvent, |
| 21 | 23 | memo, |
| 22 | 24 | type RefObject, |
| @@ -404,7 +406,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 381 unmodified lines | ||
| 404 | 406 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 405 | 407 | const shouldAutoScrollRef = useRef(true); |
| 406 | 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 | 412 | const pendingAutoScrollFrameRef = useRef<number | null>(null); |
| 409 | 413 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 410 | 414 | const composerCommandInputRef = useRef<HTMLInputElement>(null); |
| @@ -1105,7 +1109,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 694 unmodified lines | ||
| 1105 | 1109 | if (!scrollContainer) return; |
| 1106 | 1110 | scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); |
| 1107 | 1111 | lastKnownScrollTopRef.current = scrollContainer.scrollTop; |
| 1108 | − lastKnownScrollHeightRef.current = scrollContainer.scrollHeight; | |
| 1109 | 1112 | shouldAutoScrollRef.current = true; |
| 1110 | 1113 | }, []); |
| 1111 | 1114 | const requestAutoTailToBottom = useCallback(() => { |
| @@ -1120,28 +1123,59 @@ export default function ChatView({ threadId }: ChatViewProps) { · 8 unmodified lines | ||
| 1120 | 1123 | const scrollContainer = messagesScrollRef.current; |
| 1121 | 1124 | if (!scrollContainer) return; |
| 1122 | 1125 | const currentScrollTop = scrollContainer.scrollTop; |
| 1123 | − const currentScrollHeight = scrollContainer.scrollHeight; | |
| 1124 | 1126 | const atBottom = isScrollContainerAtBottom(scrollContainer); |
| 1125 | 1127 | |
| 1126 | − if (atBottom) { | |
| 1128 | + if (!shouldAutoScrollRef.current && atBottom) { | |
| 1127 | 1129 | 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) { | |
| 1130 | 1132 | 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) { | |
| 1134 | 1141 | shouldAutoScrollRef.current = false; |
| 1135 | 1142 | } |
| 1136 | 1143 | } |
| 1137 | 1144 | |
| 1138 | 1145 | lastKnownScrollTopRef.current = currentScrollTop; |
| 1139 | − lastKnownScrollHeightRef.current = currentScrollHeight; | |
| 1140 | 1146 | }, []); |
| 1141 | 1147 | const onMessagesWheel = useCallback((event: WheelEvent<HTMLDivElement>) => { |
| 1148 | + // Wheel-up indicates user intent to move away from bottom. | |
| 1142 | 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 | 1180 | useLayoutEffect(() => { |
| 1147 | 1181 | if (!activeThread?.id) return; |
| @@ -2089,6 +2123,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 941 unmodified lines | ||
| 2089 | 2123 | className="min-h-0 flex-1 overflow-y-auto px-5 py-4" |
| 2090 | 2124 | onScroll={onMessagesScroll} |
| 2091 | 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 | 2134 | <MessagesTimeline |
| 2094 | 2135 | hasMessages={activeThread.messages.length > 0} |