fix: reenable autoscroll when history hits bottom

Rafael Uzarowski committed Aug 3, 2025 at 09:47 UTC ff29b86d95a8529321906c1bca26802164e8e2ff
1 file changed +106 -61
webui/js/messages.js
+106 -61
@@ -15,6 +15,7 @@ class ScrollPositionManager {
15 this.positions = new Map();
16 this.autoscrollDisabled = false;
17 this.scrollableSelectors = ['.msg-content', '.kvps-val'];
18 + this.monitoringInterval = null; // Added for continuous monitoring
19 }
20
21 // Store scroll positions for all scrollable elements in a message
@@ -119,77 +120,63 @@ class ScrollPositionManager {
120
121 // Check global scroll state and update autoscroll disabled flag
122 checkGlobalScrollState() {
122 - // Check main chat history
123 + // Check main chat history - this is the primary indicator for disabling autoscroll
124 const chatHistory = document.getElementById("chat-history");
124 - if (chatHistory && !this.isAtBottom(chatHistory)) {
125 + if (chatHistory && !this.isAtBottom(chatHistory, 20)) {
126 this.autoscrollDisabled = true;
127 return;
128 }
129
129 - // Check chat input area
130 + // Check chat input area - also important for disabling autoscroll
131 const chatInput = document.getElementById("chat-input");
131 - if (chatInput && !this.isAtBottom(chatInput)) {
132 + if (chatInput && !this.isAtBottom(chatInput, 20)) {
133 this.autoscrollDisabled = true;
134 return;
135 }
136
136 - // Check all message content areas
137 - const allMsgContent = document.querySelectorAll('.msg-content');
138 - for (const element of allMsgContent) {
139 - if (!this.isAtBottom(element)) {
140 - this.autoscrollDisabled = true;
141 - return;
142 - }
143 - }
144 -
145 - // Check all message body areas (terminal messages)
146 - const allMsgBody = document.querySelectorAll('.message-body');
147 - for (const element of allMsgBody) {
148 - if (!this.isAtBottom(element)) {
149 - this.autoscrollDisabled = true;
150 - return;
151 - }
152 - }
153 -
154 - // Check all KVP areas
155 - const allKvpValues = document.querySelectorAll('.kvps-val');
156 - for (const element of allKvpValues) {
157 - if (!this.isAtBottom(element)) {
158 - this.autoscrollDisabled = true;
159 - return;
160 - }
161 - }
137 + // Individual message scroll positions don't disable autoscroll globally
138 + // They only affect their own scrolling behavior
139 + // This allows users to scroll up in individual messages while keeping autoscroll enabled
140
163 - // If we get here, everything is at bottom, enable autoscroll
141 + // If we get here, main areas are at bottom, enable autoscroll
142 this.autoscrollDisabled = false;
143 }
144
145 // Improved method to check if element is scrolled to bottom with better tolerance
146 isAtBottom(element, tolerance = 10) {
147 if (!element) return true;
170 - const scrollDiff = element.scrollHeight - element.scrollTop;
148 +
149 + // Get current scroll position and dimensions
150 + const scrollTop = element.scrollTop;
151 + const scrollHeight = element.scrollHeight;
152 const clientHeight = element.clientHeight;
172 - return scrollDiff <= clientHeight + tolerance;
153 +
154 + // Calculate how far from bottom we are
155 + const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
156 +
157 + // Return true if we're within tolerance of the bottom
158 + return distanceFromBottom <= tolerance;
159 }
160
175 - // Check if user is at bottom of all scrollable areas
161 + // Enhanced method to check if user is at bottom of all scrollable areas
162 + // This method is more robust during active content generation
163 isUserAtBottomOfAllScrollableAreas() {
164 // Check main chat history
165 const chatHistory = document.getElementById("chat-history");
179 - if (chatHistory && !this.isAtBottom(chatHistory)) {
166 + if (chatHistory && !this.isAtBottom(chatHistory, 20)) {
167 return false;
168 }
169
170 // Check chat input area
171 const chatInput = document.getElementById("chat-input");
185 - if (chatInput && !this.isAtBottom(chatInput)) {
172 + if (chatInput && !this.isAtBottom(chatInput, 20)) {
173 return false;
174 }
175
176 // Check all message content areas
177 const allMsgContent = document.querySelectorAll('.msg-content');
178 for (const element of allMsgContent) {
192 - if (!this.isAtBottom(element)) {
179 + if (!this.isAtBottom(element, 20)) {
180 return false;
181 }
182 }
@@ -197,7 +184,7 @@ class ScrollPositionManager {
184 // Check all message body areas (terminal messages)
185 const allMsgBody = document.querySelectorAll('.message-body');
186 for (const element of allMsgBody) {
200 - if (!this.isAtBottom(element)) {
187 + if (!this.isAtBottom(element, 20)) {
188 return false;
189 }
190 }
@@ -205,7 +192,7 @@ class ScrollPositionManager {
192 // Check all KVP areas
193 const allKvpValues = document.querySelectorAll('.kvps-val');
194 for (const element of allKvpValues) {
208 - if (!this.isAtBottom(element)) {
195 + if (!this.isAtBottom(element, 20)) {
196 return false;
197 }
198 }
@@ -213,17 +200,53 @@ class ScrollPositionManager {
200 return true;
201 }
202
203 + // Continuous monitoring method for re-enabling autoscroll during active content generation
204 + startContinuousMonitoring() {
205 + // Clear any existing monitoring
206 + if (this.monitoringInterval) {
207 + clearInterval(this.monitoringInterval);
208 + }
209 +
210 + // Start monitoring every 100ms to check if user has scrolled to bottom
211 + this.monitoringInterval = setInterval(() => {
212 + if (this.autoscrollDisabled) {
213 + // Check if main chat history is at bottom - this is the primary indicator
214 + const chatHistory = document.getElementById("chat-history");
215 + const isMainHistoryAtBottom = chatHistory && this.isAtBottom(chatHistory, 20);
216 +
217 + // Check if chat input is at bottom
218 + const chatInput = document.getElementById("chat-input");
219 + const isChatInputAtBottom = chatInput && this.isAtBottom(chatInput, 20);
220 +
221 + // If main chat history is at bottom, re-enable autoscroll regardless of individual message positions
222 + if (isMainHistoryAtBottom && isChatInputAtBottom) {
223 + this.reEnableAutoscrollIfAtBottom();
224 + }
225 + }
226 + }, 100);
227 + }
228 +
229 + // Stop continuous monitoring
230 + stopContinuousMonitoring() {
231 + if (this.monitoringInterval) {
232 + clearInterval(this.monitoringInterval);
233 + this.monitoringInterval = null;
234 + }
235 + }
236 +
237 // Set up scroll listeners for a message container
238 setupScrollListeners(messageContainer) {
239 if (!messageContainer) return;
240
241 // Add scroll listeners to detect user scrolling within messages
242 + // These don't disable autoscroll globally, they just manage their own scroll behavior
243 const msgContent = messageContainer.querySelector('.msg-content');
244 if (msgContent) {
245 msgContent.addEventListener('scroll', () => {
224 - if (!this.isAtBottom(msgContent)) {
225 - this.disableAutoscroll();
226 - } else {
246 + // Individual message scroll doesn't disable global autoscroll
247 + // It only affects the scroll behavior of this specific element
248 + if (this.isAtBottom(msgContent, 20)) {
249 + // If user scrolls back to bottom of this message, they might want autoscroll
250 this.reEnableAutoscrollIfAtBottom();
251 }
252 });
@@ -233,9 +256,10 @@ class ScrollPositionManager {
256 const msgBody = messageContainer.querySelector('.message-body');
257 if (msgBody) {
258 msgBody.addEventListener('scroll', () => {
236 - if (!this.isAtBottom(msgBody)) {
237 - this.disableAutoscroll();
238 - } else {
259 + // Individual message scroll doesn't disable global autoscroll
260 + // It only affects the scroll behavior of this specific element
261 + if (this.isAtBottom(msgBody, 20)) {
262 + // If user scrolls back to bottom of this message, they might want autoscroll
263 this.reEnableAutoscrollIfAtBottom();
264 }
265 });
@@ -244,9 +268,10 @@ class ScrollPositionManager {
268 const kvpValues = messageContainer.querySelectorAll('.kvps-val');
269 kvpValues.forEach(kvp => {
270 kvp.addEventListener('scroll', () => {
247 - if (!this.isAtBottom(kvp)) {
248 - this.disableAutoscroll();
249 - } else {
271 + // Individual KVP scroll doesn't disable global autoscroll
272 + // It only affects the scroll behavior of this specific element
273 + if (this.isAtBottom(kvp, 20)) {
274 + // If user scrolls back to bottom of this KVP, they might want autoscroll
275 this.reEnableAutoscrollIfAtBottom();
276 }
277 });
@@ -261,6 +286,8 @@ class ScrollPositionManager {
286 chatHistory.addEventListener('scroll', () => {
287 if (!this.isAtBottom(chatHistory)) {
288 this.disableAutoscroll();
289 + // Start continuous monitoring when autoscroll is disabled
290 + this.startContinuousMonitoring();
291 } else {
292 this.reEnableAutoscrollIfAtBottom();
293 }
@@ -273,6 +300,8 @@ class ScrollPositionManager {
300 chatInput.addEventListener('scroll', () => {
301 if (!this.isAtBottom(chatInput)) {
302 this.disableAutoscroll();
303 + // Start continuous monitoring when autoscroll is disabled
304 + this.startContinuousMonitoring();
305 } else {
306 this.reEnableAutoscrollIfAtBottom();
307 }
@@ -282,8 +311,20 @@ class ScrollPositionManager {
311
312 // Enhanced method to re-enable autoscroll when user scrolls to bottom
313 reEnableAutoscrollIfAtBottom() {
285 - if (this.isUserAtBottomOfAllScrollableAreas()) {
314 + // Check if main chat history is at bottom - this is the primary indicator
315 + const chatHistory = document.getElementById("chat-history");
316 + const isMainHistoryAtBottom = chatHistory && this.isAtBottom(chatHistory, 20);
317 +
318 + // Check if chat input is at bottom
319 + const chatInput = document.getElementById("chat-input");
320 + const isChatInputAtBottom = chatInput && this.isAtBottom(chatInput, 20);
321 +
322 + // If main chat history is at bottom, re-enable autoscroll regardless of individual message positions
323 + // This allows users to scroll up in individual messages but still have autoscroll when they scroll down the main history
324 + if (isMainHistoryAtBottom && isChatInputAtBottom) {
325 this.autoscrollDisabled = false;
326 + // Stop continuous monitoring when autoscroll is re-enabled
327 + this.stopContinuousMonitoring();
328 // Scroll all elements to bottom when autoscroll is enabled
329 this.scrollAllToBottom();
330 // Update the main autoscroll state
@@ -335,6 +376,8 @@ class ScrollPositionManager {
376 // Disable autoscroll globally
377 disableAutoscroll() {
378 this.autoscrollDisabled = true;
379 + // Start continuous monitoring when autoscroll is disabled
380 + this.startContinuousMonitoring();
381 // Don't call window.toggleAutoScroll here to avoid circular dependency
382 // The main autoscroll state will be updated via scrollChanged function
383 }
@@ -342,6 +385,8 @@ class ScrollPositionManager {
385 // Enable autoscroll
386 enableAutoscroll() {
387 this.autoscrollDisabled = false;
388 + // Stop continuous monitoring when autoscroll is enabled
389 + this.stopContinuousMonitoring();
390 // Automatically scroll to bottom when autoscroll is enabled
391 this.scrollAllToBottom();
392 }
@@ -585,10 +630,10 @@ export function _drawMessage(
630
631 // Set up scroll listener for new content div
632 contentDiv.addEventListener('scroll', () => {
588 - if (!scrollManager.isAtBottom(contentDiv)) {
589 - scrollManager.disableAutoscroll();
590 - } else {
591 - // Re-enable autoscroll if user scrolls back to bottom
633 + // Individual message scroll doesn't disable global autoscroll
634 + // It only affects the scroll behavior of this specific element
635 + if (scrollManager.isAtBottom(contentDiv, 20)) {
636 + // If user scrolls back to bottom of this message, they might want autoscroll
637 scrollManager.reEnableAutoscrollIfAtBottom();
638 }
639 });
@@ -636,10 +681,10 @@ export function _drawMessage(
681
682 // Set up scroll listener for new pre element
683 preElement.addEventListener('scroll', () => {
639 - if (!scrollManager.isAtBottom(preElement)) {
640 - scrollManager.disableAutoscroll();
641 - } else {
642 - // Re-enable autoscroll if user scrolls back to bottom
684 + // Individual message scroll doesn't disable global autoscroll
685 + // It only affects the scroll behavior of this specific element
686 + if (scrollManager.isAtBottom(preElement, 20)) {
687 + // If user scrolls back to bottom of this message, they might want autoscroll
688 scrollManager.reEnableAutoscrollIfAtBottom();
689 }
690 });
@@ -1211,10 +1256,10 @@ function drawKvpsIncremental(container, kvps, latex) {
1256
1257 // Set up scroll listener for new kvp value div
1258 tdiv.addEventListener('scroll', () => {
1214 - if (!scrollManager.isAtBottom(tdiv)) {
1215 - scrollManager.disableAutoscroll();
1216 - } else {
1217 - // Re-enable autoscroll if user scrolls back to bottom
1259 + // Individual KVP scroll doesn't disable global autoscroll
1260 + // It only affects the scroll behavior of this specific element
1261 + if (scrollManager.isAtBottom(tdiv, 20)) {
1262 + // If user scrolls back to bottom of this KVP, they might want autoscroll
1263 scrollManager.reEnableAutoscrollIfAtBottom();
1264 }
1265 });