- 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 | 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 | <MessagesTimeline | 2134 | <MessagesTimeline |
| 2094 | hasMessages={activeThread.messages.length > 0} | 2135 | hasMessages={activeThread.messages.length > 0} |