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