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 +}