fix: scroll-fightig free message scrolling

Rafael Uzarowski committed Jul 28, 2025 at 12:55 UTC 7647aa98eeb3014435629d13057d428e445c542a
2 files changed +481 -60
webui/index.js
+24 -3
@@ -230,7 +230,12 @@ setInterval(updateUserTime, 1000);
230
231 function setMessage(id, type, heading, content, temp, kvps = null) {
232 const result = msgs.setMessage(id, type, heading, content, temp, kvps);
233 - if (autoScroll) chatHistory.scrollTop = chatHistory.scrollHeight;
233 +
234 + // Use enhanced autoscroll logic that considers scrollable elements within messages
235 + if (autoScroll && !msgs.getScrollManager().autoscrollDisabled) {
236 + chatHistory.scrollTop = chatHistory.scrollHeight;
237 + }
238 +
239 return result;
240 }
241
@@ -765,6 +770,15 @@ export const getChatBasedId = function (id) {
770
771 window.toggleAutoScroll = async function (_autoScroll) {
772 autoScroll = _autoScroll;
773 +
774 + // Sync with scroll manager
775 + if (msgs.getScrollManager) {
776 + if (_autoScroll) {
777 + msgs.getScrollManager().enableAutoscroll();
778 + } else {
779 + msgs.getScrollManager().disableAutoscroll();
780 + }
781 + }
782 };
783
784 window.toggleJson = async function (showJson) {
@@ -1066,13 +1080,20 @@ function hideToast() {
1080 }
1081
1082 function scrollChanged(isAtBottom) {
1083 + // Check global scroll state including message-level scrollable elements
1084 + if (msgs.getScrollManager) {
1085 + msgs.getScrollManager().checkGlobalScrollState();
1086 + autoScroll = !msgs.getScrollManager().autoscrollDisabled && isAtBottom;
1087 + } else {
1088 + autoScroll = isAtBottom;
1089 + }
1090 +
1091 if (window.Alpine && autoScrollSwitch) {
1092 const inputAS = Alpine.$data(autoScrollSwitch);
1093 if (inputAS) {
1072 - inputAS.autoScroll = isAtBottom;
1094 + inputAS.autoScroll = autoScroll;
1095 }
1096 }
1075 - // autoScrollSwitch.checked = isAtBottom
1097 }
1098
1099 function updateAfterScroll() {
webui/js/messages.js
+457 -57
@@ -9,19 +9,187 @@ const chatHistory = document.getElementById("chat-history");
9
10 let messageGroup = null;
11
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 + }
19 +
20 + // Store scroll positions for all scrollable elements in a message
21 + storeMessageScrollPositions(messageContainer) {
22 + if (!messageContainer) return;
23 +
24 + const messageId = messageContainer.id;
25 + const positions = {};
26 +
27 + // Store main message content scroll position
28 + const msgContent = messageContainer.querySelector('.msg-content');
29 + if (msgContent && msgContent.scrollHeight > msgContent.clientHeight) {
30 + positions.msgContent = {
31 + scrollTop: msgContent.scrollTop,
32 + scrollHeight: msgContent.scrollHeight,
33 + clientHeight: msgContent.clientHeight,
34 + isAtBottom: this.isAtBottom(msgContent)
35 + };
36 + }
37 +
38 + // Store KVP scroll positions
39 + const kvpValues = messageContainer.querySelectorAll('.kvps-val');
40 + kvpValues.forEach((kvp, index) => {
41 + if (kvp.scrollHeight > kvp.clientHeight) {
42 + positions[`kvp_${index}`] = {
43 + scrollTop: kvp.scrollTop,
44 + scrollHeight: kvp.scrollHeight,
45 + clientHeight: kvp.clientHeight,
46 + isAtBottom: this.isAtBottom(kvp)
47 + };
48 + }
49 + });
50 +
51 + this.positions.set(messageId, positions);
52 + }
53 +
54 + // Restore scroll positions for a message
55 + restoreMessageScrollPositions(messageContainer) {
56 + if (!messageContainer) return;
57 +
58 + const messageId = messageContainer.id;
59 + const positions = this.positions.get(messageId);
60 + if (!positions) return;
61 +
62 + // Restore main message content scroll
63 + if (positions.msgContent) {
64 + const msgContent = messageContainer.querySelector('.msg-content');
65 + if (msgContent) {
66 + if (this.autoscrollDisabled || !positions.msgContent.isAtBottom) {
67 + // User had scrolled up, restore their position
68 + msgContent.scrollTop = positions.msgContent.scrollTop;
69 + } else {
70 + // User was at bottom, keep at bottom (autoscroll)
71 + msgContent.scrollTop = msgContent.scrollHeight;
72 + }
73 + }
74 + }
75 +
76 + // Restore KVP scroll positions
77 + const kvpValues = messageContainer.querySelectorAll('.kvps-val');
78 + kvpValues.forEach((kvp, index) => {
79 + const posKey = `kvp_${index}`;
80 + if (positions[posKey]) {
81 + if (this.autoscrollDisabled || !positions[posKey].isAtBottom) {
82 + // User had scrolled up, restore their position
83 + kvp.scrollTop = positions[posKey].scrollTop;
84 + } else {
85 + // User was at bottom, keep at bottom (autoscroll)
86 + kvp.scrollTop = kvp.scrollHeight;
87 + }
88 + }
89 + });
90 + }
91 +
92 + // Check if element is scrolled to bottom
93 + isAtBottom(element, tolerance = 10) {
94 + return element.scrollHeight - element.scrollTop <= element.clientHeight + tolerance;
95 + }
96 +
97 + // Set up scroll event listeners for autoscroll detection
98 + setupScrollListeners(messageContainer) {
99 + if (!messageContainer) return;
100 +
101 + // Add scroll listeners to detect user scrolling within messages
102 + const msgContent = messageContainer.querySelector('.msg-content');
103 + if (msgContent) {
104 + msgContent.addEventListener('scroll', () => {
105 + if (!this.isAtBottom(msgContent)) {
106 + this.disableAutoscroll();
107 + } else {
108 + // Re-enable autoscroll if user scrolls back to bottom
109 + this.checkGlobalScrollState();
110 + }
111 + });
112 + }
113 +
114 + const kvpValues = messageContainer.querySelectorAll('.kvps-val');
115 + kvpValues.forEach(kvp => {
116 + kvp.addEventListener('scroll', () => {
117 + if (!this.isAtBottom(kvp)) {
118 + this.disableAutoscroll();
119 + } else {
120 + // Re-enable autoscroll if user scrolls back to bottom
121 + this.checkGlobalScrollState();
122 + }
123 + });
124 + });
125 + }
126 +
127 + // Disable autoscroll globally
128 + disableAutoscroll() {
129 + this.autoscrollDisabled = true;
130 + // Don't call window.toggleAutoScroll here to avoid circular dependency
131 + // The main autoscroll state will be updated via scrollChanged function
132 + }
133 +
134 + // Enable autoscroll
135 + enableAutoscroll() {
136 + this.autoscrollDisabled = false;
137 + }
138 +
139 + // Check if autoscroll should be disabled based on any scrollable element
140 + checkGlobalScrollState() {
141 + // Check main chat history
142 + const chatHistory = document.getElementById("chat-history");
143 + if (chatHistory && !this.isAtBottom(chatHistory, 50)) {
144 + this.autoscrollDisabled = true;
145 + return;
146 + }
147 +
148 + // Check all message content areas
149 + const allMsgContent = document.querySelectorAll('.msg-content');
150 + for (let element of allMsgContent) {
151 + if (element.scrollHeight > element.clientHeight && !this.isAtBottom(element)) {
152 + this.autoscrollDisabled = true;
153 + return;
154 + }
155 + }
156 +
157 + // Check all KVP areas
158 + const allKvpValues = document.querySelectorAll('.kvps-val');
159 + for (let element of allKvpValues) {
160 + if (element.scrollHeight > element.clientHeight && !this.isAtBottom(element)) {
161 + this.autoscrollDisabled = true;
162 + return;
163 + }
164 + }
165 +
166 + // If we get here, everything is at bottom, enable autoscroll
167 + this.autoscrollDisabled = false;
168 + }
169 +}
170 +
171 +// Global scroll position manager instance
172 +const scrollManager = new ScrollPositionManager();
173 +
174 +// Export scroll manager for use in other modules
175 +export function getScrollManager() {
176 + return scrollManager;
177 +}
178 +
179 export function setMessage(id, type, heading, content, temp, kvps = null) {
180 // Search for the existing message container by id
181 let messageContainer = document.getElementById(`message-${id}`);
182 + let isNewMessage = false;
183
184 if (messageContainer) {
17 - // Don't re-render user messages
18 - // if (type === "user") {
19 - // return; // Skip re-rendering
20 - // }
21 - // For other types, update the message
22 - messageContainer.innerHTML = "";
185 + // Store current scroll positions before updating
186 + scrollManager.storeMessageScrollPositions(messageContainer);
187 +
188 + // Don't clear innerHTML - we'll do incremental updates
189 + // messageContainer.innerHTML = "";
190 } else {
191 // Create a new container if not found
192 + isNewMessage = true;
193 const sender = type === "user" ? "user" : "ai";
194 messageContainer = document.createElement("div");
195 messageContainer.id = `message-${id}`;
@@ -32,8 +200,8 @@ export function setMessage(id, type, heading, content, temp, kvps = null) {
200 const handler = getHandler(type);
201 handler(messageContainer, id, type, heading, content, temp, kvps);
202
35 - // If the container was found, it was already in the DOM, no need to append again
36 - if (!document.getElementById(`message-${id}`)) {
203 + // If this is a new message, handle DOM insertion
204 + if (isNewMessage && !document.getElementById(`message-${id}`)) {
205 // message type visual grouping
206 const groupTypeMap = {
207 user: "right",
@@ -71,6 +239,14 @@ export function setMessage(id, type, heading, content, temp, kvps = null) {
239
240 messageGroup.appendChild(messageContainer);
241 chatHistory.appendChild(messageGroup);
242 +
243 + // Set up scroll listeners for new message
244 + scrollManager.setupScrollListeners(messageContainer);
245 + } else {
246 + // For existing messages, restore scroll positions after DOM update
247 + setTimeout(() => {
248 + scrollManager.restoreMessageScrollPositions(messageContainer);
249 + }, 0);
250 }
251 }
252
@@ -161,42 +337,94 @@ export function _drawMessage(
337 markdown = false,
338 resizeBtns = true
339 ) {
164 - const messageDiv = document.createElement("div");
165 - messageDiv.classList.add("message", mainClass, ...messageClasses);
340 + // Find existing message div or create new one
341 + let messageDiv = messageContainer.querySelector(".message");
342 + if (!messageDiv) {
343 + messageDiv = document.createElement("div");
344 + messageDiv.classList.add("message");
345 + messageContainer.appendChild(messageDiv);
346 + }
347 +
348 + // Update message classes
349 + messageDiv.className = `message ${mainClass} ${messageClasses.join(' ')}`;
350
351 + // Handle heading
352 if (heading) {
168 - const headingElement = document.createElement("div");
169 - headingElement.classList.add("msg-heading");
170 - const headingH4 = document.createElement("h4");
353 + let headingElement = messageDiv.querySelector(".msg-heading");
354 + if (!headingElement) {
355 + headingElement = document.createElement("div");
356 + headingElement.classList.add("msg-heading");
357 + messageDiv.insertBefore(headingElement, messageDiv.firstChild);
358 + }
359 +
360 + let headingH4 = headingElement.querySelector("h4");
361 + if (!headingH4) {
362 + headingH4 = document.createElement("h4");
363 + headingElement.appendChild(headingH4);
364 + }
365 headingH4.innerHTML = convertIcons(escapeHTML(heading));
172 - headingElement.appendChild(headingH4);
173 - messageDiv.appendChild(headingElement);
366
367 if (resizeBtns) {
176 - const minMaxBtn = document.createElement("div");
177 - minMaxBtn.classList.add("msg-min-max-btns");
178 - minMaxBtn.innerHTML = `
179 - <a href="#" class="msg-min-max-btn" @click.prevent="$store.messageResize.minimizeMessageClass('${mainClass}', $event)"><span class="material-symbols-outlined" x-text="$store.messageResize.getSetting('${mainClass}').minimized ? 'expand_content' : 'minimize'"></span></a>
180 - <a href="#" class="msg-min-max-btn" x-show="!$store.messageResize.getSetting('${mainClass}').minimized" @click.prevent="$store.messageResize.maximizeMessageClass('${mainClass}', $event)"><span class="material-symbols-outlined" x-text="$store.messageResize.getSetting('${mainClass}').maximized ? 'expand' : 'expand_all'"></span></a>
181 - `;
182 - headingElement.appendChild(minMaxBtn);
368 + let minMaxBtn = headingElement.querySelector(".msg-min-max-btns");
369 + if (!minMaxBtn) {
370 + minMaxBtn = document.createElement("div");
371 + minMaxBtn.classList.add("msg-min-max-btns");
372 + minMaxBtn.innerHTML = `
373 + <a href="#" class="msg-min-max-btn" @click.prevent="$store.messageResize.minimizeMessageClass('${mainClass}', $event)"><span class="material-symbols-outlined" x-text="$store.messageResize.getSetting('${mainClass}').minimized ? 'expand_content' : 'minimize'"></span></a>
374 + <a href="#" class="msg-min-max-btn" x-show="!$store.messageResize.getSetting('${mainClass}').minimized" @click.prevent="$store.messageResize.maximizeMessageClass('${mainClass}', $event)"><span class="material-symbols-outlined" x-text="$store.messageResize.getSetting('${mainClass}').maximized ? 'expand' : 'expand_all'"></span></a>
375 + `;
376 + headingElement.appendChild(minMaxBtn);
377 + }
378 + }
379 + } else {
380 + // Remove heading if it exists but heading is null
381 + const existingHeading = messageDiv.querySelector(".msg-heading");
382 + if (existingHeading) {
383 + existingHeading.remove();
384 }
385 }
386
186 - const bodyDiv = document.createElement("div");
187 - bodyDiv.classList.add("message-body");
188 - messageDiv.appendChild(bodyDiv);
387 + // Find existing body div or create new one
388 + let bodyDiv = messageDiv.querySelector(".message-body");
389 + if (!bodyDiv) {
390 + bodyDiv = document.createElement("div");
391 + bodyDiv.classList.add("message-body");
392 + messageDiv.appendChild(bodyDiv);
393 + }
394
190 - drawKvps(bodyDiv, kvps, false);
395 + // Handle KVPs incrementally
396 + drawKvpsIncremental(bodyDiv, kvps, false);
397
398 + // Handle content
399 if (content && content.trim().length > 0) {
400 if (markdown) {
194 - const contentDiv = document.createElement("div");
195 - contentDiv.classList.add("msg-content", ...contentClasses);
401 + let contentDiv = bodyDiv.querySelector(".msg-content");
402 + if (!contentDiv) {
403 + contentDiv = document.createElement("div");
404 + contentDiv.classList.add("msg-content", ...contentClasses);
405 + bodyDiv.appendChild(contentDiv);
406 +
407 + // Set up scroll listener for new content div
408 + contentDiv.addEventListener('scroll', () => {
409 + if (!scrollManager.isAtBottom(contentDiv)) {
410 + scrollManager.disableAutoscroll();
411 + } else {
412 + // Re-enable autoscroll if user scrolls back to bottom
413 + scrollManager.checkGlobalScrollState();
414 + }
415 + });
416 + } else {
417 + // Update classes
418 + contentDiv.className = `msg-content ${contentClasses.join(' ')}`;
419 + }
420
197 - const spanElement = document.createElement("span"); // Wrapper span
198 - let processedContent = content;
421 + let spanElement = contentDiv.querySelector("span");
422 + if (!spanElement) {
423 + spanElement = document.createElement("span");
424 + contentDiv.appendChild(spanElement);
425 + }
426
427 + let processedContent = content;
428 processedContent = convertImageTags(processedContent);
429 processedContent = convertImgFilePaths(processedContent);
430 processedContent = marked.parse(processedContent, { breaks: true });
@@ -213,41 +441,66 @@ export function _drawMessage(
441 });
442 }
443
216 - contentDiv.appendChild(spanElement);
217 - addCopyButtonToElement(contentDiv);
444 + // Ensure copy button exists
445 + if (!contentDiv.querySelector(".copy-button")) {
446 + addCopyButtonToElement(contentDiv);
447 + }
448 adjustMarkdownRender(contentDiv);
219 - bodyDiv.appendChild(contentDiv);
449 } else {
221 - const preElement = document.createElement("pre");
222 - preElement.classList.add("msg-content", ...contentClasses);
223 - preElement.style.whiteSpace = "pre-wrap";
224 - preElement.style.wordBreak = "break-word";
450 + let preElement = bodyDiv.querySelector(".msg-content");
451 + if (!preElement) {
452 + preElement = document.createElement("pre");
453 + preElement.classList.add("msg-content", ...contentClasses);
454 + preElement.style.whiteSpace = "pre-wrap";
455 + preElement.style.wordBreak = "break-word";
456 + bodyDiv.appendChild(preElement);
457 +
458 + // Set up scroll listener for new pre element
459 + preElement.addEventListener('scroll', () => {
460 + if (!scrollManager.isAtBottom(preElement)) {
461 + scrollManager.disableAutoscroll();
462 + } else {
463 + // Re-enable autoscroll if user scrolls back to bottom
464 + scrollManager.checkGlobalScrollState();
465 + }
466 + });
467 + } else {
468 + // Update classes
469 + preElement.className = `msg-content ${contentClasses.join(' ')}`;
470 + }
471
226 - const spanElement = document.createElement("span");
227 - spanElement.innerHTML = convertHTML(content);
472 + let spanElement = preElement.querySelector("span");
473 + if (!spanElement) {
474 + spanElement = document.createElement("span");
475 + preElement.appendChild(spanElement);
476
229 - // Add click handler for small screens
230 - spanElement.addEventListener("click", () => {
231 - copyText(spanElement.textContent, spanElement);
232 - });
477 + // Add click handler for small screens (only once)
478 + spanElement.addEventListener("click", () => {
479 + copyText(spanElement.textContent, spanElement);
480 + });
481 + }
482 +
483 + spanElement.innerHTML = convertHTML(content);
484
234 - preElement.appendChild(spanElement);
235 - addCopyButtonToElement(preElement);
236 - bodyDiv.appendChild(preElement);
485 + // Ensure copy button exists
486 + if (!preElement.querySelector(".copy-button")) {
487 + addCopyButtonToElement(preElement);
488 + }
489 + }
490 + } else {
491 + // Remove content if it exists but content is empty
492 + const existingContent = bodyDiv.querySelector(".msg-content");
493 + if (existingContent) {
494 + existingContent.remove();
495 }
496 }
497
240 - messageContainer.appendChild(messageDiv);
241 -
498 if (followUp) {
499 messageContainer.classList.add("message-followup");
500 }
501
246 - // autoscroll the body if needed
247 - // if (getAutoScroll()) #TODO needs a better redraw system
248 - setTimeout(() => {
249 - bodyDiv.scrollTop = bodyDiv.scrollHeight;
250 - }, 0);
502 + // Don't force scroll here - let the scroll manager handle it
503 + // The scroll manager will decide whether to autoscroll based on user's scroll state
504
505 return messageDiv;
506 }
@@ -434,7 +687,7 @@ export function drawMessageUser(
687 } else {
688 // Render as file tile with title and icon
689 attachmentDiv.classList.add("file-type");
437 -
690 +
691 // File icon
692 if (displayInfo.previewUrl && displayInfo.previewUrl !== displayInfo.filename) {
693 const iconImg = document.createElement("img");
@@ -443,12 +696,12 @@ export function drawMessageUser(
696 iconImg.classList.add("file-icon");
697 attachmentDiv.appendChild(iconImg);
698 }
446 -
699 +
700 // File title
701 const fileTitle = document.createElement("div");
702 fileTitle.classList.add("file-title");
703 fileTitle.textContent = displayInfo.filename;
451 -
704 +
705 attachmentDiv.appendChild(fileTitle);
706 }
707
@@ -727,6 +980,153 @@ function drawKvps(container, kvps, latex) {
980 }
981 }
982
983 +function drawKvpsIncremental(container, kvps, latex) {
984 + if (kvps) {
985 + // Find existing table or create new one
986 + let table = container.querySelector(".msg-kvps");
987 + if (!table) {
988 + table = document.createElement("table");
989 + table.classList.add("msg-kvps");
990 + container.appendChild(table);
991 + }
992 +
993 + // Get all current rows for comparison
994 + let existingRows = table.querySelectorAll(".kvps-row");
995 + const kvpEntries = Object.entries(kvps);
996 +
997 + // Update or create rows as needed
998 + kvpEntries.forEach(([key, value], index) => {
999 + let row = existingRows[index];
1000 +
1001 + if (!row) {
1002 + // Create new row if it doesn't exist
1003 + row = table.insertRow();
1004 + row.classList.add("kvps-row");
1005 + }
1006 +
1007 + // Update row classes
1008 + row.className = "kvps-row";
1009 + if (key === "thoughts" || key === "reasoning") {
1010 + row.classList.add("msg-thoughts");
1011 + }
1012 +
1013 + // Handle key cell
1014 + let th = row.querySelector(".kvps-key");
1015 + if (!th) {
1016 + th = row.insertCell(0);
1017 + th.classList.add("kvps-key");
1018 + }
1019 + th.textContent = convertToTitleCase(key);
1020 +
1021 + // Handle value cell
1022 + let td = row.cells[1];
1023 + if (!td) {
1024 + td = row.insertCell(1);
1025 + }
1026 +
1027 + let tdiv = td.querySelector(".kvps-val");
1028 + if (!tdiv) {
1029 + tdiv = document.createElement("div");
1030 + tdiv.classList.add("kvps-val");
1031 + td.appendChild(tdiv);
1032 +
1033 + // Set up scroll listener for new kvp value div
1034 + tdiv.addEventListener('scroll', () => {
1035 + if (!scrollManager.isAtBottom(tdiv)) {
1036 + scrollManager.disableAutoscroll();
1037 + } else {
1038 + // Re-enable autoscroll if user scrolls back to bottom
1039 + scrollManager.checkGlobalScrollState();
1040 + }
1041 + });
1042 + }
1043 +
1044 + // Store current scroll position
1045 + const currentScrollTop = tdiv.scrollTop;
1046 + const isAtBottom = tdiv.scrollHeight - tdiv.scrollTop <= tdiv.clientHeight + 10;
1047 +
1048 + // Clear and rebuild content (for now - could be optimized further)
1049 + tdiv.innerHTML = "";
1050 +
1051 + if (Array.isArray(value)) {
1052 + for (const item of value) {
1053 + addValue(item, tdiv);
1054 + }
1055 + } else {
1056 + addValue(value, tdiv);
1057 + }
1058 +
1059 + // Restore scroll position or autoscroll
1060 + setTimeout(() => {
1061 + if (!scrollManager.autoscrollDisabled && isAtBottom) {
1062 + // User was at bottom, keep at bottom
1063 + tdiv.scrollTop = tdiv.scrollHeight;
1064 + } else {
1065 + // Restore previous position
1066 + tdiv.scrollTop = currentScrollTop;
1067 + }
1068 + }, 0);
1069 + });
1070 +
1071 + // Remove extra rows if we have fewer kvps now
1072 + while (existingRows.length > kvpEntries.length) {
1073 + const lastRow = existingRows[existingRows.length - 1];
1074 + lastRow.remove();
1075 + existingRows = table.querySelectorAll(".kvps-row");
1076 + }
1077 +
1078 + function addValue(value, tdiv) {
1079 + if (typeof value === "object") value = JSON.stringify(value, null, 2);
1080 +
1081 + if (typeof value === "string" && value.startsWith("img://")) {
1082 + const imgElement = document.createElement("img");
1083 + imgElement.classList.add("kvps-img");
1084 + imgElement.src = value.replace("img://", "/image_get?path=");
1085 + imgElement.alt = "Image Attachment";
1086 + tdiv.appendChild(imgElement);
1087 +
1088 + // Add click handler and cursor change
1089 + imgElement.style.cursor = "pointer";
1090 + imgElement.addEventListener("click", () => {
1091 + openImageModal(imgElement.src, 1000);
1092 + });
1093 + } else {
1094 + const pre = document.createElement("pre");
1095 + const span = document.createElement("span");
1096 + span.innerHTML = convertHTML(value);
1097 + pre.appendChild(span);
1098 + tdiv.appendChild(pre);
1099 +
1100 + // Only add copy button if it doesn't exist
1101 + const row = tdiv.closest(".kvps-row");
1102 + if (row && !row.querySelector(".copy-button")) {
1103 + addCopyButtonToElement(row);
1104 + }
1105 +
1106 + // Add click handler
1107 + span.addEventListener("click", () => {
1108 + copyText(span.textContent, span);
1109 + });
1110 +
1111 + // KaTeX rendering for markdown
1112 + if (latex) {
1113 + span.querySelectorAll("latex").forEach((element) => {
1114 + katex.render(element.innerHTML, element, {
1115 + throwOnError: false,
1116 + });
1117 + });
1118 + }
1119 + }
1120 + }
1121 + } else {
1122 + // Remove table if kvps is null/empty
1123 + const existingTable = container.querySelector(".msg-kvps");
1124 + if (existingTable) {
1125 + existingTable.remove();
1126 + }
1127 + }
1128 +}
1129 +
1130 function convertToTitleCase(str) {
1131 return str
1132 .replace(/_/g, " ") // Replace underscores with spaces
@@ -841,4 +1241,4 @@ function adjustMarkdownRender(element) {
1241 el.parentNode.insertBefore(wrapper, el);
1242 wrapper.appendChild(el);
1243 });
844 -}
\ No newline at end of file
1244 +}