autoscroll polishing
frdel committed
Aug 4, 2025 at 22:26 UTC
481e12bd92ba3e09ee981c1c170bc0416dc7da1c
2 files changed
+95
-340
webui/index.js
+1
-1
@@ -1036,7 +1036,7 @@ function scrollChanged(isAtBottom) {
1036
function updateAfterScroll() {
1037
// const toleranceEm = 1; // Tolerance in em units
1038
// const tolerancePx = toleranceEm * parseFloat(getComputedStyle(document.documentElement).fontSize); // Convert em to pixels
1039
- const tolerancePx = 50;
1039
+ const tolerancePx = 10;
1040
const chatHistory = document.getElementById("chat-history");
1041
const isAtBottom =
1042
chatHistory.scrollHeight - chatHistory.scrollTop <=
webui/js/messages.js
+94
-339
@@ -1,7 +1,6 @@
1
// copy button
2
import { openImageModal } from "./image_modal.js";
3
import { marked } from "../vendor/marked/marked.esm.js";
4
-import { getAutoScroll } from "/index.js";
4
import { store as _messageResizeStore } from "/components/messages/resize/message-resize-store.js"; // keep here, required in html
5
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
6
@@ -9,280 +8,15 @@ const chatHistory = document.getElementById("chat-history");
8
9
let messageGroup = null;
10
12
-// Scroll position manager for smooth autoscroll
13
-class ScrollPositionManager {
14
- constructor() {
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
22
- storeMessageScrollPositions(messageContainer) {
23
- // Disabled to prevent scroll position resets
24
- return;
25
- }
26
-
27
- // Restore scroll positions for a message
28
- restoreMessageScrollPositions(messageContainer) {
29
- // Disabled to prevent scroll position resets
30
- return;
31
- }
32
-
33
- // Check global scroll state and update autoscroll disabled flag
34
- checkGlobalScrollState() {
35
- // Check main chat history - this is the primary indicator for disabling autoscroll
36
- const chatHistory = document.getElementById("chat-history");
37
- if (chatHistory && !this.isAtBottom(chatHistory, 20)) {
38
- this.autoscrollDisabled = true;
39
- return;
40
- }
41
-
42
- // Check chat input area - also important for disabling autoscroll
43
- const chatInput = document.getElementById("chat-input");
44
- if (chatInput && !this.isAtBottom(chatInput, 20)) {
45
- this.autoscrollDisabled = true;
46
- return;
47
- }
48
-
49
- // Individual message scroll positions don't disable autoscroll globally
50
- // They only affect their own scrolling behavior
51
- // This allows users to scroll up in individual messages while keeping autoscroll enabled
52
-
53
- // If we get here, main areas are at bottom, enable autoscroll
54
- this.autoscrollDisabled = false;
55
- }
56
-
57
- // Improved method to check if element is scrolled to bottom with better tolerance
58
- isAtBottom(element, tolerance = 10) {
59
- if (!element) return true;
60
-
61
- // Get current scroll position and dimensions
62
- const scrollTop = element.scrollTop;
63
- const scrollHeight = element.scrollHeight;
64
- const clientHeight = element.clientHeight;
65
-
66
- // Calculate how far from bottom we are
67
- const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
68
-
69
- // Return true if we're within tolerance of the bottom
70
- return distanceFromBottom <= tolerance;
71
- }
72
-
73
- // Enhanced method to check if user is at bottom of all scrollable areas
74
- // This method is more robust during active content generation
75
- isUserAtBottomOfAllScrollableAreas() {
76
- // Check main chat history
77
- const chatHistory = document.getElementById("chat-history");
78
- if (chatHistory && !this.isAtBottom(chatHistory, 20)) {
79
- return false;
80
- }
81
-
82
- // Check chat input area
83
- const chatInput = document.getElementById("chat-input");
84
- if (chatInput && !this.isAtBottom(chatInput, 20)) {
85
- return false;
86
- }
87
-
88
- // Check all message content areas
89
- const allMsgContent = document.querySelectorAll('.msg-content');
90
- for (const element of allMsgContent) {
91
- if (!this.isAtBottom(element, 20)) {
92
- return false;
93
- }
94
- }
95
-
96
- // Check all message body areas (terminal messages)
97
- const allMsgBody = document.querySelectorAll('.message-body');
98
- for (const element of allMsgBody) {
99
- if (!this.isAtBottom(element, 20)) {
100
- return false;
101
- }
102
- }
103
-
104
- // Check all KVP areas
105
- const allKvpValues = document.querySelectorAll('.kvps-val');
106
- for (const element of allKvpValues) {
107
- if (!this.isAtBottom(element, 20)) {
108
- return false;
109
- }
110
- }
111
-
112
- return true;
113
- }
114
-
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
-
121
- // Check if chat input is at bottom
122
- const chatInput = document.getElementById("chat-input");
123
- const isChatInputAtBottom = chatInput && this.isAtBottom(chatInput, 20);
124
-
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
- }
135
- }
136
- }
137
-
138
- // Set up scroll listeners for a message container
139
- setupScrollListeners(messageContainer) {
140
- if (!messageContainer) return;
141
-
142
- // Add scroll listeners to detect user scrolling within messages
143
- // These don't disable autoscroll globally, they just manage their own scroll behavior
144
- const msgContent = messageContainer.querySelector('.msg-content');
145
- if (msgContent) {
146
- msgContent.addEventListener('scroll', () => {
147
- // Individual message scroll doesn't disable global autoscroll
148
- // It only affects the scroll behavior of this specific element
149
- if (this.isAtBottom(msgContent, 20)) {
150
- // If user scrolls back to bottom of this message, they might want autoscroll
151
- this.reEnableAutoscrollIfAtBottom();
152
- }
153
- });
154
- }
155
-
156
- // Add scroll listeners for message-body elements (terminal messages)
157
- const msgBody = messageContainer.querySelector('.message-body');
158
- if (msgBody) {
159
- msgBody.addEventListener('scroll', () => {
160
- // Individual message scroll doesn't disable global autoscroll
161
- // It only affects the scroll behavior of this specific element
162
- if (this.isAtBottom(msgBody, 20)) {
163
- // If user scrolls back to bottom of this message, they might want autoscroll
164
- this.reEnableAutoscrollIfAtBottom();
165
- }
166
- });
167
- }
168
-
169
- const kvpValues = messageContainer.querySelectorAll('.kvps-val');
170
- kvpValues.forEach(kvp => {
171
- kvp.addEventListener('scroll', () => {
172
- // Individual KVP scroll doesn't disable global autoscroll
173
- // It only affects the scroll behavior of this specific element
174
- if (this.isAtBottom(kvp, 20)) {
175
- // If user scrolls back to bottom of this KVP, they might want autoscroll
176
- this.reEnableAutoscrollIfAtBottom();
177
- }
178
- });
179
- });
180
- }
181
-
182
- // Set up scroll listeners for global elements (chat history, chat input)
183
- setupGlobalScrollListeners() {
184
- // Set up scroll listener for main chat history
185
- const chatHistory = document.getElementById("chat-history");
186
- if (chatHistory) {
187
- chatHistory.addEventListener('scroll', () => {
188
- if (!this.isAtBottom(chatHistory)) {
189
- this.disableAutoscroll();
190
- } else {
191
- this.reEnableAutoscrollIfAtBottom();
192
- }
193
- });
194
- }
195
-
196
- // Set up scroll listener for chat input
197
- const chatInput = document.getElementById("chat-input");
198
- if (chatInput) {
199
- chatInput.addEventListener('scroll', () => {
200
- if (!this.isAtBottom(chatInput)) {
201
- this.disableAutoscroll();
202
- } else {
203
- this.reEnableAutoscrollIfAtBottom();
204
- }
205
- });
206
- }
207
- }
208
-
209
- // Method to scroll all scrollable elements to the bottom
210
- scrollAllToBottom() {
211
- // Scroll main chat history to bottom
212
- const chatHistory = document.getElementById("chat-history");
213
- if (chatHistory) {
214
- chatHistory.scrollTop = chatHistory.scrollHeight;
215
- }
216
-
217
- // Scroll chat input to bottom
218
- const chatInput = document.getElementById("chat-input");
219
- if (chatInput) {
220
- chatInput.scrollTop = chatInput.scrollHeight;
221
- }
222
-
223
- // Scroll all message content areas to bottom
224
- const allMsgContent = document.querySelectorAll('.msg-content');
225
- allMsgContent.forEach(element => {
226
- if (element.scrollHeight > element.clientHeight) {
227
- element.scrollTop = element.scrollHeight;
228
- }
229
- });
230
-
231
- // Scroll all message body areas (terminal messages) to bottom
232
- const allMsgBody = document.querySelectorAll('.message-body');
233
- allMsgBody.forEach(element => {
234
- if (element.scrollHeight > element.clientHeight) {
235
- element.scrollTop = element.scrollHeight;
236
- }
237
- });
238
-
239
- // Scroll all KVP areas to bottom
240
- const allKvpValues = document.querySelectorAll('.kvps-val');
241
- allKvpValues.forEach(element => {
242
- if (element.scrollHeight > element.clientHeight) {
243
- element.scrollTop = element.scrollHeight;
244
- }
245
- });
246
- }
247
-
248
- // Disable autoscroll globally
249
- disableAutoscroll() {
250
- this.autoscrollDisabled = true;
251
- // Start continuous monitoring when autoscroll is disabled
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
- }
256
-
257
- // Enable autoscroll
258
- enableAutoscroll() {
259
- this.autoscrollDisabled = false;
260
- // Stop continuous monitoring when autoscroll is enabled
261
- // this.stopContinuousMonitoring(); // Removed continuous monitoring
262
- // Automatically scroll to bottom when autoscroll is enabled
263
- this.scrollAllToBottom();
264
- }
265
-}
266
-
267
-// Global scroll position manager instance
268
-const scrollManager = new ScrollPositionManager();
269
-
270
-// Export scroll manager for use in other modules
271
-export function getScrollManager() {
272
- return scrollManager;
273
-}
274
-
11
export function setMessage(id, type, heading, content, temp, kvps = null) {
12
// Search for the existing message container by id
13
let messageContainer = document.getElementById(`message-${id}`);
278
- let isNewMessage = false;
14
15
if (messageContainer) {
16
// Don't clear innerHTML - we'll do incremental updates
17
// messageContainer.innerHTML = "";
18
} else {
19
// Create a new container if not found
285
- isNewMessage = true;
20
const sender = type === "user" ? "user" : "ai";
21
messageContainer = document.createElement("div");
22
messageContainer.id = `message-${id}`;
@@ -293,24 +27,43 @@ export function setMessage(id, type, heading, content, temp, kvps = null) {
27
handler(messageContainer, id, type, heading, content, temp, kvps);
28
29
// If this is a new message, handle DOM insertion
296
- if (isNewMessage && !document.getElementById(`message-${id}`)) {
30
+ if (!document.getElementById(`message-${id}`)) {
31
// message type visual grouping
32
const groupTypeMap = {
299
- user: "message-group-right",
300
- ai: "message-group-mid",
301
- tool: "message-group-mid",
302
- default: "message-group-mid",
33
+ user: "right",
34
+ info: "mid",
35
+ warning: "mid",
36
+ error: "mid",
37
+ rate_limit: "mid",
38
+ util: "mid",
39
+ hint: "mid",
40
+ // anything else is "left"
41
+ };
42
+ //force new group on these types
43
+ const groupStart = {
44
+ agent: true,
45
+ // anything else is false
46
};
304
- const groupType = groupTypeMap[type] || "message-group-mid";
305
- messageGroup = document.createElement("div");
306
- messageGroup.classList.add("message-group", groupType);
47
+
48
+ const groupType = groupTypeMap[type] || "left";
49
+
50
+ // here check if messageGroup is still in DOM, if not, then set it to null (context switch)
51
+ if (messageGroup && !document.getElementById(messageGroup.id))
52
+ messageGroup = null;
53
+
54
+ if (
55
+ !messageGroup || // no group yet exists
56
+ groupStart[type] || // message type forces new group
57
+ groupType != messageGroup.getAttribute("data-group-type") // message type changes group
58
+ ) {
59
+ messageGroup = document.createElement("div");
60
+ messageGroup.id = `message-group-${id}`;
61
+ messageGroup.classList.add(`message-group`, `message-group-${groupType}`);
62
+ messageGroup.setAttribute("data-group-type", groupType);
63
+ }
64
messageGroup.appendChild(messageContainer);
65
chatHistory.appendChild(messageGroup);
309
-
310
- // Set up scroll listeners for new message
311
- scrollManager.setupScrollListeners(messageContainer);
66
}
313
-
67
return messageContainer;
68
}
69
@@ -410,7 +163,7 @@ export function _drawMessage(
163
}
164
165
// Update message classes
413
- messageDiv.className = `message ${mainClass} ${messageClasses.join(' ')}`;
166
+ messageDiv.className = `message ${mainClass} ${messageClasses.join(" ")}`;
167
168
// Handle heading
169
if (heading) {
@@ -465,22 +218,9 @@ export function _drawMessage(
218
let contentDiv = bodyDiv.querySelector(".msg-content");
219
if (!contentDiv) {
220
contentDiv = document.createElement("div");
468
- contentDiv.classList.add("msg-content", ...contentClasses);
221
bodyDiv.appendChild(contentDiv);
470
-
471
- // Set up scroll listener for new content div
472
- contentDiv.addEventListener('scroll', () => {
473
- // Individual message scroll doesn't disable global autoscroll
474
- // It only affects the scroll behavior of this specific element
475
- if (scrollManager.isAtBottom(contentDiv, 20)) {
476
- // If user scrolls back to bottom of this message, they might want autoscroll
477
- scrollManager.reEnableAutoscrollIfAtBottom();
478
- }
479
- });
480
- } else {
481
- // Update classes
482
- contentDiv.className = `msg-content ${contentClasses.join(' ')}`;
222
}
223
+ contentDiv.className = `msg-content ${contentClasses.join(" ")}`;
224
225
let spanElement = contentDiv.querySelector("span");
226
if (!spanElement) {
@@ -494,6 +234,10 @@ export function _drawMessage(
234
processedContent = marked.parse(processedContent, { breaks: true });
235
processedContent = convertPathsToLinks(processedContent);
236
processedContent = addBlankTargetsToLinks(processedContent);
237
+
238
+ // reapply scroll position or autoscroll
239
+ const scroller = new Scroller(contentDiv);
240
+
241
spanElement.innerHTML = processedContent;
242
243
// KaTeX rendering for markdown
@@ -510,6 +254,9 @@ export function _drawMessage(
254
addCopyButtonToElement(contentDiv);
255
}
256
adjustMarkdownRender(contentDiv);
257
+
258
+ // reapply scroll position or autoscroll
259
+ scroller.reApplyScroll();
260
} else {
261
let preElement = bodyDiv.querySelector(".msg-content");
262
if (!preElement) {
@@ -518,19 +265,9 @@ export function _drawMessage(
265
preElement.style.whiteSpace = "pre-wrap";
266
preElement.style.wordBreak = "break-word";
267
bodyDiv.appendChild(preElement);
521
-
522
- // Set up scroll listener for new pre element
523
- preElement.addEventListener('scroll', () => {
524
- // Individual message scroll doesn't disable global autoscroll
525
- // It only affects the scroll behavior of this specific element
526
- if (scrollManager.isAtBottom(preElement, 20)) {
527
- // If user scrolls back to bottom of this message, they might want autoscroll
528
- scrollManager.reEnableAutoscrollIfAtBottom();
529
- }
530
- });
268
} else {
269
// Update classes
533
- preElement.className = `msg-content ${contentClasses.join(' ')}`;
270
+ preElement.className = `msg-content ${contentClasses.join(" ")}`;
271
}
272
273
let spanElement = preElement.querySelector("span");
@@ -544,12 +281,18 @@ export function _drawMessage(
281
});
282
}
283
284
+ // reapply scroll position or autoscroll
285
+ const scroller = new Scroller(preElement);
286
+
287
spanElement.innerHTML = convertHTML(content);
288
289
// Ensure copy button exists
290
if (!preElement.querySelector(".copy-button")) {
291
addCopyButtonToElement(preElement);
292
}
293
+
294
+ // reapply scroll position or autoscroll
295
+ scroller.reApplyScroll();
296
}
297
} else {
298
// Remove content if it exists but content is empty
@@ -563,26 +306,30 @@ export function _drawMessage(
306
messageContainer.classList.add("message-followup");
307
}
308
566
- // Don't force scroll here - let the scroll manager handle it
567
- // The scroll manager will decide whether to autoscroll based on user's scroll state
568
-
309
return messageDiv;
310
}
311
312
export function addBlankTargetsToLinks(str) {
573
- const doc = new DOMParser().parseFromString(str, 'text/html');
574
-
575
- doc.querySelectorAll('a').forEach(anchor => {
576
- const href = anchor.getAttribute('href') || '';
577
- if (href.startsWith('#') || href.trim().toLowerCase().startsWith('javascript')) return;
578
- if (!anchor.hasAttribute('target') || anchor.getAttribute('target') === '') {
579
- anchor.setAttribute('target', '_blank');
313
+ const doc = new DOMParser().parseFromString(str, "text/html");
314
+
315
+ doc.querySelectorAll("a").forEach((anchor) => {
316
+ const href = anchor.getAttribute("href") || "";
317
+ if (
318
+ href.startsWith("#") ||
319
+ href.trim().toLowerCase().startsWith("javascript")
320
+ )
321
+ return;
322
+ if (
323
+ !anchor.hasAttribute("target") ||
324
+ anchor.getAttribute("target") === ""
325
+ ) {
326
+ anchor.setAttribute("target", "_blank");
327
}
328
582
- const rel = (anchor.getAttribute('rel') || '').split(/\s+/).filter(Boolean);
583
- if (!rel.includes('noopener')) rel.push('noopener');
584
- if (!rel.includes('noreferrer')) rel.push('noreferrer');
585
- anchor.setAttribute('rel', rel.join(' '));
329
+ const rel = (anchor.getAttribute("rel") || "").split(/\s+/).filter(Boolean);
330
+ if (!rel.includes("noopener")) rel.push("noopener");
331
+ if (!rel.includes("noreferrer")) rel.push("noreferrer");
332
+ anchor.setAttribute("rel", rel.join(" "));
333
});
334
return doc.body.innerHTML;
335
}
@@ -704,8 +451,7 @@ export function drawMessageUser(
451
452
const headingElement = document.createElement("h4");
453
headingElement.classList.add("msg-heading");
707
- headingElement.innerHTML =
708
- `${heading} <span class='icon material-symbols-outlined'>person</span>`;
454
+ headingElement.innerHTML = `${heading} <span class='icon material-symbols-outlined'>person</span>`;
455
messageDiv.appendChild(headingElement);
456
457
if (content && content.trim().length > 0) {
@@ -746,14 +492,16 @@ export function drawMessageUser(
492
img.classList.add("attachment-preview");
493
img.style.cursor = "pointer";
494
749
-
495
attachmentDiv.appendChild(img);
496
} else {
497
// Render as file tile with title and icon
498
attachmentDiv.classList.add("file-type");
499
500
// File icon
756
- if (displayInfo.previewUrl && displayInfo.previewUrl !== displayInfo.filename) {
501
+ if (
502
+ displayInfo.previewUrl &&
503
+ displayInfo.previewUrl !== displayInfo.filename
504
+ ) {
505
const iconImg = document.createElement("img");
506
iconImg.src = displayInfo.previewUrl;
507
iconImg.alt = `${displayInfo.extension} file`;
@@ -769,7 +517,7 @@ export function drawMessageUser(
517
attachmentDiv.appendChild(fileTitle);
518
}
519
772
- attachmentDiv.addEventListener('click', displayInfo.clickHandler);
520
+ attachmentDiv.addEventListener("click", displayInfo.clickHandler);
521
522
attachmentsContainer.appendChild(attachmentDiv);
523
});
@@ -1038,7 +786,6 @@ function drawKvps(container, kvps, latex) {
786
}
787
}
788
}
1041
-
789
}
790
container.appendChild(table);
791
}
@@ -1093,21 +840,10 @@ function drawKvpsIncremental(container, kvps, latex) {
840
tdiv = document.createElement("div");
841
tdiv.classList.add("kvps-val");
842
td.appendChild(tdiv);
1096
-
1097
- // Set up scroll listener for new kvp value div
1098
- tdiv.addEventListener('scroll', () => {
1099
- // Individual KVP scroll doesn't disable global autoscroll
1100
- // It only affects the scroll behavior of this specific element
1101
- if (scrollManager.isAtBottom(tdiv, 20)) {
1102
- // If user scrolls back to bottom of this KVP, they might want autoscroll
1103
- scrollManager.reEnableAutoscrollIfAtBottom();
1104
- }
1105
- });
843
}
844
1108
- // Store current scroll position
1109
- const currentScrollTop = tdiv.scrollTop;
1110
- const isAtBottom = tdiv.scrollHeight - tdiv.scrollTop <= tdiv.clientHeight + 10;
845
+ // reapply scroll position or autoscroll
846
+ const scroller = new Scroller(tdiv);
847
848
// Clear and rebuild content (for now - could be optimized further)
849
tdiv.innerHTML = "";
@@ -1120,8 +856,8 @@ function drawKvpsIncremental(container, kvps, latex) {
856
addValue(value, tdiv);
857
}
858
1123
- // Don't restore scroll position to prevent resets
1124
- // Let the natural scroll behavior work
859
+ // reapply scroll position or autoscroll
860
+ scroller.reApplyScroll();
861
});
862
863
// Remove extra rows if we have fewer kvps now
@@ -1298,3 +1034,22 @@ function adjustMarkdownRender(element) {
1034
wrapper.appendChild(el);
1035
});
1036
}
1037
+
1038
+class Scroller {
1039
+ constructor(element) {
1040
+ this.element = element;
1041
+ this.wasAtBottom = this.isAtBottom();
1042
+ }
1043
+
1044
+ isAtBottom(tolerance = 10) {
1045
+ const scrollHeight = this.element.scrollHeight;
1046
+ const clientHeight = this.element.clientHeight;
1047
+ const distanceFromBottom =
1048
+ scrollHeight - this.element.scrollTop - clientHeight;
1049
+ return distanceFromBottom <= tolerance;
1050
+ }
1051
+
1052
+ reApplyScroll() {
1053
+ if (this.wasAtBottom) this.element.scrollTop = this.element.scrollHeight;
1054
+ }
1055
+}