try to fix scroll stuttering in output message

Rafael Uzarowski committed Aug 3, 2025 at 15:14 UTC 37b8a23f2d4c8feeff80b34a2a4f5c8bff35e5a2
3 files changed +144 -211
webui/css/messages.css
+5 -3
@@ -402,9 +402,11 @@
402 }
403
404 .msg-content {
405 - margin-bottom: 0;
406 - padding: 0;
407 - overflow: hidden;
405 + max-height: 30em;
406 + overflow-y: auto;
407 + overflow-x: hidden;
408 + word-wrap: break-word;
409 + white-space: pre-wrap;
410 }
411
412 .message-temp {
webui/index.js
+105 -1
@@ -1113,11 +1113,112 @@ function updateAfterScroll() {
1113 chatHistory.scrollHeight - chatHistory.scrollTop <=
1114 chatHistory.clientHeight + tolerancePx;
1115
1116 - scrollChanged(isAtBottom);
1116 + // Check if any scrollable message content areas are scrolled up
1117 + const allMsgContent = document.querySelectorAll('.msg-content');
1118 + let anyMsgContentScrolledUp = false;
1119 + for (const element of allMsgContent) {
1120 + if (element.scrollHeight > element.clientHeight) {
1121 + const elementIsAtBottom = element.scrollHeight - element.scrollTop <= element.clientHeight + tolerancePx;
1122 + if (!elementIsAtBottom) {
1123 + anyMsgContentScrolledUp = true;
1124 + break;
1125 + }
1126 + }
1127 + }
1128 +
1129 + // Check if any KVP value areas are scrolled up
1130 + const allKvpValues = document.querySelectorAll('.kvps-val');
1131 + let anyKvpScrolledUp = false;
1132 + for (const element of allKvpValues) {
1133 + if (element.scrollHeight > element.clientHeight) {
1134 + const elementIsAtBottom = element.scrollHeight - element.scrollTop <= element.clientHeight + tolerancePx;
1135 + if (!elementIsAtBottom) {
1136 + anyKvpScrolledUp = true;
1137 + break;
1138 + }
1139 + }
1140 + }
1141 +
1142 + // Check if any message body areas (terminal messages) are scrolled up
1143 + const allMsgBody = document.querySelectorAll('.message-body');
1144 + let anyMsgBodyScrolledUp = false;
1145 + for (const element of allMsgBody) {
1146 + if (element.scrollHeight > element.clientHeight) {
1147 + const elementIsAtBottom = element.scrollHeight - element.scrollTop <= element.clientHeight + tolerancePx;
1148 + if (!elementIsAtBottom) {
1149 + anyMsgBodyScrolledUp = true;
1150 + break;
1151 + }
1152 + }
1153 + }
1154 +
1155 + // Only consider at bottom if main history is at bottom AND no individual areas are scrolled up
1156 + const isReallyAtBottom = isAtBottom && !anyMsgContentScrolledUp && !anyKvpScrolledUp && !anyMsgBodyScrolledUp;
1157 +
1158 + scrollChanged(isReallyAtBottom);
1159 }
1160
1161 chatHistory.addEventListener("scroll", updateAfterScroll);
1162
1163 +// Set up scroll listeners for all scrollable message content areas
1164 +function setupMessageScrollListeners() {
1165 + // Set up scroll listeners for msg-content elements
1166 + const allMsgContent = document.querySelectorAll('.msg-content');
1167 + allMsgContent.forEach(element => {
1168 + if (element.scrollHeight > element.clientHeight) {
1169 + element.addEventListener('scroll', updateAfterScroll);
1170 + }
1171 + });
1172 +
1173 + // Set up scroll listeners for kvps-val elements
1174 + const allKvpValues = document.querySelectorAll('.kvps-val');
1175 + allKvpValues.forEach(element => {
1176 + if (element.scrollHeight > element.clientHeight) {
1177 + element.addEventListener('scroll', updateAfterScroll);
1178 + }
1179 + });
1180 +
1181 + // Set up scroll listeners for message-body elements (terminal messages)
1182 + const allMsgBody = document.querySelectorAll('.message-body');
1183 + allMsgBody.forEach(element => {
1184 + if (element.scrollHeight > element.clientHeight) {
1185 + element.addEventListener('scroll', updateAfterScroll);
1186 + }
1187 + });
1188 +}
1189 +
1190 +// Function to add scroll listeners to new message content
1191 +function addScrollListenersToMessage(messageContainer) {
1192 + if (!messageContainer) return;
1193 +
1194 + // Add scroll listeners to msg-content elements
1195 + const msgContentElements = messageContainer.querySelectorAll('.msg-content');
1196 + msgContentElements.forEach(element => {
1197 + if (element.scrollHeight > element.clientHeight) {
1198 + element.addEventListener('scroll', updateAfterScroll);
1199 + }
1200 + });
1201 +
1202 + // Add scroll listeners to kvps-val elements
1203 + const kvpElements = messageContainer.querySelectorAll('.kvps-val');
1204 + kvpElements.forEach(element => {
1205 + if (element.scrollHeight > element.clientHeight) {
1206 + element.addEventListener('scroll', updateAfterScroll);
1207 + }
1208 + });
1209 +
1210 + // Add scroll listeners to message-body elements
1211 + const msgBodyElements = messageContainer.querySelectorAll('.message-body');
1212 + msgBodyElements.forEach(element => {
1213 + if (element.scrollHeight > element.clientHeight) {
1214 + element.addEventListener('scroll', updateAfterScroll);
1215 + }
1216 + });
1217 +}
1218 +
1219 +// Make function available globally
1220 +window.addScrollListenersToMessage = addScrollListenersToMessage;
1221 +
1222 chatInput.addEventListener("input", adjustTextareaHeight);
1223
1224 // setInterval(poll, 250);
@@ -1154,6 +1255,9 @@ document.addEventListener("DOMContentLoaded", function () {
1255 setupSidebarToggle();
1256 setupTabs();
1257 initializeActiveTab();
1258 +
1259 + // Set up scroll listeners for existing message content
1260 + setupMessageScrollListeners();
1261 });
1262
1263 // Setup tabs functionality
webui/js/messages.js
+34 -207
@@ -20,102 +20,14 @@ class ScrollPositionManager {
20
21 // Store scroll positions for all scrollable elements in a message
22 storeMessageScrollPositions(messageContainer) {
23 - if (!messageContainer) return;
24 -
25 - const messageId = messageContainer.id;
26 - const positions = {};
27 -
28 - // Store main message content scroll position
29 - const msgContent = messageContainer.querySelector('.msg-content');
30 - if (msgContent && msgContent.scrollHeight > msgContent.clientHeight) {
31 - positions.msgContent = {
32 - scrollTop: msgContent.scrollTop,
33 - scrollHeight: msgContent.scrollHeight,
34 - clientHeight: msgContent.clientHeight,
35 - isAtBottom: this.isAtBottom(msgContent)
36 - };
37 - }
38 -
39 - // Store message body scroll position (for terminal messages)
40 - const msgBody = messageContainer.querySelector('.message-body');
41 - if (msgBody && msgBody.scrollHeight > msgBody.clientHeight) {
42 - positions.msgBody = {
43 - scrollTop: msgBody.scrollTop,
44 - scrollHeight: msgBody.scrollHeight,
45 - clientHeight: msgBody.clientHeight,
46 - isAtBottom: this.isAtBottom(msgBody)
47 - };
48 - }
49 -
50 - // Store KVP scroll positions
51 - const kvpValues = messageContainer.querySelectorAll('.kvps-val');
52 - kvpValues.forEach((kvp, index) => {
53 - if (kvp.scrollHeight > kvp.clientHeight) {
54 - positions[`kvp_${index}`] = {
55 - scrollTop: kvp.scrollTop,
56 - scrollHeight: kvp.scrollHeight,
57 - clientHeight: kvp.clientHeight,
58 - isAtBottom: this.isAtBottom(kvp)
59 - };
60 - }
61 - });
62 -
63 - this.positions.set(messageId, positions);
23 + // Disabled to prevent scroll position resets
24 + return;
25 }
26
27 // Restore scroll positions for a message
28 restoreMessageScrollPositions(messageContainer) {
68 - if (!messageContainer) return;
69 -
70 - const messageId = messageContainer.id;
71 - const positions = this.positions.get(messageId);
72 - if (!positions) return;
73 -
74 - // Use a small delay to ensure DOM is fully updated
75 - setTimeout(() => {
76 - // Restore main message content scroll
77 - if (positions.msgContent) {
78 - const msgContent = messageContainer.querySelector('.msg-content');
79 - if (msgContent) {
80 - if (this.autoscrollDisabled || !positions.msgContent.isAtBottom) {
81 - // User had scrolled up, restore their position
82 - msgContent.scrollTop = positions.msgContent.scrollTop;
83 - } else {
84 - // User was at bottom, keep at bottom (autoscroll)
85 - msgContent.scrollTop = msgContent.scrollHeight;
86 - }
87 - }
88 - }
89 -
90 - // Restore message body scroll position (for terminal messages)
91 - if (positions.msgBody) {
92 - const msgBody = messageContainer.querySelector('.message-body');
93 - if (msgBody) {
94 - if (this.autoscrollDisabled || !positions.msgBody.isAtBottom) {
95 - // User had scrolled up, restore their position
96 - msgBody.scrollTop = positions.msgBody.scrollTop;
97 - } else {
98 - // User was at bottom, keep at bottom (autoscroll)
99 - msgBody.scrollTop = msgBody.scrollHeight;
100 - }
101 - }
102 - }
103 -
104 - // Restore KVP scroll positions
105 - const kvpValues = messageContainer.querySelectorAll('.kvps-val');
106 - kvpValues.forEach((kvp, index) => {
107 - const posKey = `kvp_${index}`;
108 - if (positions[posKey]) {
109 - if (this.autoscrollDisabled || !positions[posKey].isAtBottom) {
110 - // User had scrolled up, restore their position
111 - kvp.scrollTop = positions[posKey].scrollTop;
112 - } else {
113 - // User was at bottom, keep at bottom (autoscroll)
114 - kvp.scrollTop = kvp.scrollHeight;
115 - }
116 - }
117 - });
118 - }, 10); // Small delay to ensure DOM is ready
29 + // Disabled to prevent scroll position resets
30 + return;
31 }
32
33 // Check global scroll state and update autoscroll disabled flag
@@ -200,37 +112,26 @@ class ScrollPositionManager {
112 return true;
113 }
114
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);
115 + // Enhanced method to re-enable autoscroll when user scrolls to bottom
116 + reEnableAutoscrollIfAtBottom() {
117 + // Check if main chat history is at bottom - this is the primary indicator
118 + const chatHistory = document.getElementById("chat-history");
119 + const isMainHistoryAtBottom = chatHistory && this.isAtBottom(chatHistory, 20);
120
217 - // Check if chat input is at bottom
218 - const chatInput = document.getElementById("chat-input");
219 - const isChatInputAtBottom = chatInput && this.isAtBottom(chatInput, 20);
121 + // Check if chat input is at bottom
122 + const chatInput = document.getElementById("chat-input");
123 + const isChatInputAtBottom = chatInput && this.isAtBottom(chatInput, 20);
124
221 - // If main chat history is at bottom, re-enable autoscroll regardless of individual message positions
222 - if (isMainHistoryAtBottom && isChatInputAtBottom) {
223 - this.reEnableAutoscrollIfAtBottom();
224 - }
125 + // If main chat history is at bottom, re-enable autoscroll regardless of individual message positions
126 + // This allows users to scroll up in individual messages but still have autoscroll when they scroll down the main history
127 + if (isMainHistoryAtBottom && isChatInputAtBottom) {
128 + this.autoscrollDisabled = false;
129 + // Scroll all elements to bottom when autoscroll is enabled
130 + this.scrollAllToBottom();
131 + // Update the main autoscroll state
132 + if (window.updateAfterScroll) {
133 + window.updateAfterScroll();
134 }
226 - }, 100);
227 - }
228 -
229 - // Stop continuous monitoring
230 - stopContinuousMonitoring() {
231 - if (this.monitoringInterval) {
232 - clearInterval(this.monitoringInterval);
233 - this.monitoringInterval = null;
135 }
136 }
137
@@ -286,8 +187,6 @@ class ScrollPositionManager {
187 chatHistory.addEventListener('scroll', () => {
188 if (!this.isAtBottom(chatHistory)) {
189 this.disableAutoscroll();
289 - // Start continuous monitoring when autoscroll is disabled
290 - this.startContinuousMonitoring();
190 } else {
191 this.reEnableAutoscrollIfAtBottom();
192 }
@@ -300,8 +199,6 @@ class ScrollPositionManager {
199 chatInput.addEventListener('scroll', () => {
200 if (!this.isAtBottom(chatInput)) {
201 this.disableAutoscroll();
303 - // Start continuous monitoring when autoscroll is disabled
304 - this.startContinuousMonitoring();
202 } else {
203 this.reEnableAutoscrollIfAtBottom();
204 }
@@ -309,31 +206,6 @@ class ScrollPositionManager {
206 }
207 }
208
312 - // Enhanced method to re-enable autoscroll when user scrolls to bottom
313 - reEnableAutoscrollIfAtBottom() {
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
331 - if (window.updateAfterScroll) {
332 - window.updateAfterScroll();
333 - }
334 - }
335 - }
336 -
209 // Method to scroll all scrollable elements to the bottom
210 scrollAllToBottom() {
211 // Scroll main chat history to bottom
@@ -377,7 +249,7 @@ class ScrollPositionManager {
249 disableAutoscroll() {
250 this.autoscrollDisabled = true;
251 // Start continuous monitoring when autoscroll is disabled
380 - this.startContinuousMonitoring();
252 + // this.startContinuousMonitoring(); // Removed continuous monitoring
253 // Don't call window.toggleAutoScroll here to avoid circular dependency
254 // The main autoscroll state will be updated via scrollChanged function
255 }
@@ -386,7 +258,7 @@ class ScrollPositionManager {
258 enableAutoscroll() {
259 this.autoscrollDisabled = false;
260 // Stop continuous monitoring when autoscroll is enabled
389 - this.stopContinuousMonitoring();
261 + // this.stopContinuousMonitoring(); // Removed continuous monitoring
262 // Automatically scroll to bottom when autoscroll is enabled
263 this.scrollAllToBottom();
264 }
@@ -406,9 +278,6 @@ export function setMessage(id, type, heading, content, temp, kvps = null) {
278 let isNewMessage = false;
279
280 if (messageContainer) {
409 - // Store current scroll positions before updating
410 - scrollManager.storeMessageScrollPositions(messageContainer);
411 -
281 // Don't clear innerHTML - we'll do incremental updates
282 // messageContainer.innerHTML = "";
283 } else {
@@ -418,7 +287,6 @@ export function setMessage(id, type, heading, content, temp, kvps = null) {
287 messageContainer = document.createElement("div");
288 messageContainer.id = `message-${id}`;
289 messageContainer.classList.add("message-container", `${sender}-container`);
421 - // if (temp) messageContainer.classList.add("message-temp");
290 }
291
292 const handler = getHandler(type);
@@ -428,50 +296,22 @@ export function setMessage(id, type, heading, content, temp, kvps = null) {
296 if (isNewMessage && !document.getElementById(`message-${id}`)) {
297 // message type visual grouping
298 const groupTypeMap = {
431 - user: "right",
432 - info: "mid",
433 - warning: "mid",
434 - error: "mid",
435 - rate_limit: "mid",
436 - util: "mid",
437 - hint: "mid",
438 - // anything else is "left"
439 - };
440 -
441 - //force new group on these types
442 - const groupStart = {
443 - agent: true,
444 - // anything else is false
299 + user: "message-group-right",
300 + ai: "message-group-mid",
301 + tool: "message-group-mid",
302 + default: "message-group-mid",
303 };
446 -
447 - const groupType = groupTypeMap[type] || "left";
448 -
449 - // here check if messageGroup is still in DOM, if not, then set it to null (context switch)
450 - if(messageGroup && !document.getElementById(messageGroup.id))
451 - messageGroup = null;
452 -
453 - if (
454 - !messageGroup || // no group yet exists
455 - groupStart[type] || // message type forces new group
456 - groupType != messageGroup.getAttribute("data-group-type") // message type changes group
457 - ) {
458 - messageGroup = document.createElement("div");
459 - messageGroup.id = `message-group-${id}`;
460 - messageGroup.classList.add(`message-group`, `message-group-${groupType}`);
461 - messageGroup.setAttribute("data-group-type", groupType);
462 - }
463 -
304 + const groupType = groupTypeMap[type] || "message-group-mid";
305 + messageGroup = document.createElement("div");
306 + messageGroup.classList.add("message-group", groupType);
307 messageGroup.appendChild(messageContainer);
308 chatHistory.appendChild(messageGroup);
309
310 // Set up scroll listeners for new message
311 scrollManager.setupScrollListeners(messageContainer);
469 - } else {
470 - // For existing messages, restore scroll positions after DOM update
471 - setTimeout(() => {
472 - scrollManager.restoreMessageScrollPositions(messageContainer);
473 - }, 0);
312 }
313 +
314 + return messageContainer;
315 }
316
317 function createCopyButton() {
@@ -1280,21 +1120,8 @@ function drawKvpsIncremental(container, kvps, latex) {
1120 addValue(value, tdiv);
1121 }
1122
1283 - // Restore scroll position or autoscroll
1284 - setTimeout(() => {
1285 - if (!scrollManager.autoscrollDisabled && isAtBottom) {
1286 - // User was at bottom, keep at bottom
1287 - tdiv.scrollTop = tdiv.scrollHeight;
1288 - } else if (!scrollManager.autoscrollDisabled) {
1289 - // User was at bottom, keep at bottom (autoscroll)
1290 - tdiv.scrollTop = tdiv.scrollHeight;
1291 - } else {
1292 - // Restore previous position only if user had scrolled up
1293 - if (currentScrollTop > 0) {
1294 - tdiv.scrollTop = currentScrollTop;
1295 - }
1296 - }
1297 - }, 10);
1123 + // Don't restore scroll position to prevent resets
1124 + // Let the natural scroll behavior work
1125 });
1126
1127 // Remove extra rows if we have fewer kvps now