fixing scrolling, markdown, subordinates
frdel committed
Jan 30, 2026 at 15:21 UTC
0094aff20f4daa6a4e509f71c75efbcd293b1616
7 files changed
+104
-94
webui/components/chat/navigation/chat-navigation-store.js
+3
-3
@@ -40,13 +40,13 @@ const model = {
40
41
if (currentIndex > 0) {
42
// Go to previous message
43
- positions[currentIndex - 1].el.scrollIntoView({ block: "start" });
43
+ positions[currentIndex - 1].el.scrollIntoView({ block: "start", behavior: "smooth" });
44
} else if (currentIndex === 0) {
45
// At the first message, scroll to top
46
scroller.scrollTo({ top: 0, behavior: "instant" });
47
} else if (currentIndex === -1 && positions.length > 0) {
48
// All messages are above the threshold (scrolled past), scroll to bottom
49
- positions[positions.length - 1].el.scrollIntoView({ block: "start" });
49
+ positions[positions.length - 1].el.scrollIntoView({ block: "start", behavior: "smooth" });
50
}
51
},
52
@@ -67,7 +67,7 @@ const model = {
67
68
if (targetIndex !== -1) {
69
// Go to that message
70
- positions[targetIndex].el.scrollIntoView({ block: "start" });
70
+ positions[targetIndex].el.scrollIntoView({ block: "start", behavior: "smooth" });
71
} else {
72
// No message found below threshold => scroll to bottom
73
this.scrollToBottom();
webui/components/messages/action-buttons/simple-action-buttons.css
+3
-15
@@ -126,26 +126,14 @@
126
width: 100%;
127
}
128
129
-.code-block-wrapper > .step-action-buttons,
130
-.message-markdown-table-wrap > .step-action-buttons {
131
- padding-top: 0;
132
-}
129
+
130
131
/* ===========================================
132
Hover behavior - Pointer devices
133
=========================================== */
134
138
-.device-pointer .message-user:hover > .step-action-buttons,
139
-.device-pointer .message-agent-response:hover > .step-action-buttons,
140
-.device-pointer .message.message-collapsible.expanded:hover > .step-action-buttons,
141
-.device-pointer .process-step:hover > .process-step-detail .step-action-buttons {
142
- opacity: 1;
143
- pointer-events: auto;
144
-}
145
-
146
-/* Code blocks and tables: show copy button on hover */
147
-.device-pointer .code-block-wrapper:hover > .step-action-buttons,
148
-.device-pointer .message-markdown-table-wrap:hover > .step-action-buttons {
135
+.device-pointer .step-action-buttons:hover,
136
+.device-pointer :hover > .step-action-buttons {
137
opacity: 1;
138
pointer-events: auto;
139
}
webui/components/messages/process-group/process-group.css
+7
-1
@@ -174,13 +174,19 @@
174
--step-accent: #b45309;
175
}
176
177
-/* SUB - subagent type (teal) */
177
+/* SUB and RES - subagent type and response (teal) */
178
.process-group .SUB {
179
--step-accent: #14b8a6;
180
}
181
.light-mode .process-group .SUB {
182
--step-accent: #0f766e;
183
}
184
+.process-group .RES {
185
+ --step-accent: #14b8a6;
186
+}
187
+.light-mode .process-group .RES {
188
+ --step-accent: #0f766e;
189
+}
190
191
/* EXE - code_exe type (magenta/purple) */
192
.process-group .EXE {
webui/components/sidebar/chats/chats-store.js
+1
-16
@@ -4,7 +4,6 @@ import {
4
getContext,
5
setContext,
6
poll as triggerPoll,
7
- updateAfterScroll,
7
toastFetchError,
8
toast,
9
justToast,
@@ -57,9 +56,6 @@ const model = {
56
57
// Trigger immediate poll
58
triggerPoll();
60
-
61
- // Update scroll
62
- updateAfterScroll();
59
},
60
61
// Delete a chat
@@ -129,9 +125,7 @@ const model = {
125
// Increment reset counter
126
if (typeof globalThis.resetCounter === 'number') {
127
globalThis.resetCounter = globalThis.resetCounter + 1;
132
- }
133
-
134
- updateAfterScroll();
128
+ }
129
} catch (e) {
130
toastFetchError("Error resetting chat", e);
131
}
@@ -151,15 +145,6 @@ const model = {
145
return;
146
}
147
154
-
155
- // if (globalThis.newContext) {
156
- // globalThis.newContext();
157
- // }
158
- // if (globalThis.updateAfterScroll) {
159
- // globalThis.updateAfterScroll();
160
- // }
161
- // // UX: scroll-to-top
162
- // requestAnimationFrame(() => this._scrollChatsToTop());
148
} catch (e) {
149
toastFetchError("Error creating new chat", e);
150
}
webui/css/messages.css
+19
-4
@@ -244,11 +244,15 @@
244
color: #24292f;
245
}
246
247
+.message-body .markdown-block-wrap {
248
+ margin: 1em 0;
249
+}
250
+
251
.message-body .message-markdown-table-wrap {
252
display: block;
253
width: 100%;
254
overflow-x: auto;
251
- padding-bottom: 1em;
255
+ /* padding-bottom: 1em; */
256
}
257
258
.message-body .message-markdown-table-wrap table {
@@ -257,6 +261,18 @@
261
white-space: nowrap;
262
}
263
264
+.message-body .code-block-wrapper{
265
+ max-width: 100%;
266
+ overflow-x: auto;
267
+ padding: 0.5em;
268
+ border: 1px solid var(--color-border);
269
+ border-radius: var(--border-radius-sm);
270
+}
271
+
272
+.message-body .code-block-wrapper pre{
273
+ max-width: 100%;
274
+}
275
+
276
/* .message-body code {
277
white-space: break-spaces;
278
} */
@@ -581,7 +597,6 @@
597
.msg-content table {
598
width: 100%;
599
border-collapse: collapse;
584
- margin: 1em 0;
600
font-size: 0.98em;
601
background: transparent;
602
border-radius: var(--border-radius-sm);
@@ -613,11 +628,11 @@
628
box-shadow: none;
629
}
630
616
-.msg-content pre:has(code) {
631
+/* .msg-content pre:has(code) {
632
padding: 0.5em;
633
border: 1px solid var(--color-border);
634
border-radius: var(--border-radius-sm);
620
-}
635
+} */
636
637
.msg-content hr {
638
border: 0;
webui/index.js
-27
@@ -578,28 +578,6 @@ export function toast(text, type = "info", timeout = 5000) {
578
}
579
globalThis.toast = toast;
580
581
-// OLD: hideToast function removed - now using new notification system
582
-
583
-function scrollChanged(isAtBottom) {
584
- // Reflect scroll state into preferences store; UI is bound via x-model
585
- preferencesStore.autoScroll = isAtBottom;
586
-}
587
-
588
-export function updateAfterScroll() {
589
- // const toleranceEm = 1; // Tolerance in em units
590
- // const tolerancePx = toleranceEm * parseFloat(getComputedStyle(document.documentElement).fontSize); // Convert em to pixels
591
- // Larger trigger zone near bottom for autoscroll
592
- const tolerancePx = 80;
593
- const chatHistory = document.getElementById("chat-history");
594
- if (!chatHistory) return;
595
-
596
- const isAtBottom =
597
- chatHistory.scrollHeight - chatHistory.scrollTop <=
598
- chatHistory.clientHeight + tolerancePx;
599
-
600
- scrollChanged(isAtBottom);
601
-}
602
-globalThis.updateAfterScroll = updateAfterScroll;
581
582
import { store as _chatNavigationStore } from "/components/chat/navigation/chat-navigation-store.js";
583
@@ -650,11 +628,6 @@ document.addEventListener("DOMContentLoaded", function () {
628
autoScrollSwitch = document.getElementById("auto-scroll-switch");
629
timeDate = document.getElementById("time-date-container");
630
653
- // Sidebar and input event listeners are now handled by their respective stores
654
-
655
- if (chatHistory) {
656
- chatHistory.addEventListener("scroll", updateAfterScroll);
657
- }
631
632
// Start polling for updates
633
startPolling();
webui/js/messages.js
+71
-28
@@ -13,10 +13,12 @@ import { store as preferencesStore } from "/components/sidebar/bottom/preference
13
import { formatDuration } from "./time-utils.js";
14
15
// Delay before collapsing previous steps when a new step is added
16
-const STEP_COLLAPSE_DELAY_MS = 3000;
16
+const STEP_COLLAPSE_DELAY = {
17
+ "agent": 2000,
18
+ "other": 4000, // tools should stay longer as next gen step is placed quickly
19
+}
20
+// delay collapse when hovering
21
const STEP_COLLAPSE_HOVER_DELAY_MS = 5000;
18
-// Delay before collapsing the last step when processing completes
19
-const FINAL_STEP_COLLAPSE_DELAY_MS = 3000;
22
23
// dom references
24
let _chatHistory = null;
@@ -72,11 +74,8 @@ export function setMessages(messages) {
74
const cutoff = isLargeAppend ? Math.max(0, messages.length - 2) : 0;
75
const massRender = historyEmpty || isLargeAppend;
76
75
- let mainScroller;
76
- if (preferencesStore.autoScroll && history) {
77
- mainScroller = new Scroller(history, { smooth: !massRender });
78
- }
79
-
77
+ const mainScroller = new Scroller(history, { smooth: !massRender, toleranceRem: 6 });
78
+
79
// process messages
80
for (let i = 0; i < messages.length; i++) {
81
_massRender = historyEmpty || (isLargeAppend && i < cutoff);
@@ -86,7 +85,7 @@ export function setMessages(messages) {
85
// reset _massRender flag
86
_massRender = false;
87
89
- if (mainScroller) mainScroller.reApplyScroll();
88
+ mainScroller.reApplyScroll();
89
}
90
91
// entrypoint called from poll/WS communication, this is how all messages are rendered and updated
@@ -298,9 +297,9 @@ function drawProcessStep({
297
stepsContainer
298
.querySelectorAll(".process-step.expanded")
299
.forEach((expandedStep) => {
301
- if (expandedStep.id !== stepId) {
302
- scheduleStepCollapse(expandedStep, STEP_COLLAPSE_DELAY_MS);
303
- }
300
+ const delay = STEP_COLLAPSE_DELAY[expandedStep.getAttribute("data-log-type")] || STEP_COLLAPSE_DELAY.other;
301
+ console.log("collapsing", expandedStep.getAttribute("data-log-type"), delay);
302
+ scheduleStepCollapse(expandedStep, delay);
303
});
304
step.classList.add("expanded");
305
}
@@ -364,7 +363,7 @@ function drawProcessStep({
363
364
// auto-scroller of the step detail
365
const detailScroller = new Scroller(stepDetailScroll, {
367
- smooth: !isMassRender(),
366
+ smooth: !isMassRender(), toleranceRem: 4
367
}); // scroller for step detail content
368
369
// update KVPs of the step detail
@@ -725,15 +724,15 @@ export function drawMessageResponse({
724
return drawProcessStep({
725
id,
726
title,
728
- // statusClass,
729
- statusCode: "RSP",
730
- kvps,
727
+ code: "RES",
728
+ kvps: {},
729
type,
730
heading,
731
content,
732
timestamp,
733
agentno,
734
actionButtons,
735
+ log: arguments[0],
736
});
737
}
738
@@ -769,7 +768,7 @@ export function drawMessageResponse({
768
markdown: true,
769
latex: true,
770
mainClass: "message-agent-response",
772
- smoothStream: false,// !isMassRender(), // stream smoothly if not in mass render mode
771
+ smoothStream: false ,// smooth render disabled, not reliable yet !isMassRender(), // stream smoothly if not in mass render mode
772
});
773
774
// Collapsible with action buttons
@@ -1582,6 +1581,7 @@ function adjustMarkdownRender(element) {
1581
const tables = element.querySelectorAll("table");
1582
tables.forEach((el) => {
1583
const wrapper = wrapElement(el, "message-markdown-table-wrap");
1584
+ const outerWrapper = wrapElement(wrapper, "markdown-block-wrap");
1585
const actionsDiv = document.createElement("div");
1586
actionsDiv.className = "step-action-buttons";
1587
actionsDiv.appendChild(
@@ -1589,7 +1589,7 @@ function adjustMarkdownRender(element) {
1589
copyToClipboard(extractTableTSV(el)),
1590
),
1591
);
1592
- wrapper.appendChild(actionsDiv);
1592
+ outerWrapper.appendChild(actionsDiv);
1593
});
1594
1595
// find all code blocks
@@ -1597,12 +1597,13 @@ function adjustMarkdownRender(element) {
1597
codeElements.forEach((code) => {
1598
const pre = code.parentNode;
1599
const wrapper = wrapElement(pre, "code-block-wrapper");
1600
+ const outerWrapper = wrapElement(wrapper, "markdown-block-wrap");
1601
const actionsDiv = document.createElement("div");
1602
actionsDiv.className = "step-action-buttons";
1603
actionsDiv.appendChild(
1604
createActionButton("copy", "", () => copyToClipboard(code.textContent)),
1605
);
1605
- wrapper.appendChild(actionsDiv);
1606
+ outerWrapper.appendChild(actionsDiv);
1607
});
1608
1609
// find all images
@@ -1624,22 +1625,64 @@ function adjustMarkdownRender(element) {
1625
}
1626
1627
export class Scroller {
1627
- constructor(element, { smooth = false } = {}) {
1628
+ constructor(element, { smooth = false, toleranceRem = 2 } = {}) {
1629
this.element = element;
1630
this.smooth = smooth;
1631
+ this.tolerance = toleranceRem * parseFloat(getComputedStyle(document.documentElement).fontSize);
1632
this.wasAtBottom = this.isAtBottom();
1633
+ this._scrollListener = null;
1634
}
1635
1633
- isAtBottom(tolerance = 80) {
1634
- const { scrollHeight, clientHeight, scrollTop } = this.element;
1635
- return scrollHeight - scrollTop - clientHeight <= tolerance;
1636
+ _getEffectiveScrollTop() {
1637
+ const scrollingToRaw = this.element?.dataset?.scrollingTo;
1638
+ const scrollingTo = scrollingToRaw == null ? null : Number(scrollingToRaw);
1639
+ if (Number.isFinite(scrollingTo)) return scrollingTo;
1640
+ return this.element.scrollTop;
1641
+ }
1642
+
1643
+ _setScrollingTo(target) {
1644
+ this.element.dataset.scrollingTo = String(target);
1645
+
1646
+ if (this._scrollListener) return;
1647
+
1648
+ this._scrollListener = () => {
1649
+ const current = this.element.scrollTop;
1650
+ const activeTargetRaw = this.element?.dataset?.scrollingTo;
1651
+ const activeTarget = activeTargetRaw == null ? null : Number(activeTargetRaw);
1652
+ if (!Number.isFinite(activeTarget)) {
1653
+ this._clearScrollingTo();
1654
+ return;
1655
+ }
1656
+
1657
+ if (current >= activeTarget - 1) this._clearScrollingTo();
1658
+ };
1659
+
1660
+ this.element.addEventListener("scroll", this._scrollListener, { passive: true });
1661
+ }
1662
+
1663
+ _clearScrollingTo() {
1664
+ delete this.element.dataset.scrollingTo;
1665
+ if (this._scrollListener) {
1666
+ this.element.removeEventListener("scroll", this._scrollListener);
1667
+ this._scrollListener = null;
1668
+ }
1669
+ }
1670
+
1671
+ isAtBottom() {
1672
+ const { scrollHeight, clientHeight } = this.element;
1673
+ const scrollTop = this._getEffectiveScrollTop();
1674
+ return scrollHeight - scrollTop - clientHeight <= this.tolerance;
1675
}
1676
1677
scrollToBottom() {
1639
- const target = this.element.scrollHeight;
1640
- this.smooth
1641
- ? this.element.scrollTo({ top: target, behavior: "smooth" })
1642
- : (this.element.scrollTop = target);
1678
+ const target = Math.max(0, this.element.scrollHeight - this.element.clientHeight);
1679
+ if (this.smooth) {
1680
+ this._setScrollingTo(target);
1681
+ this.element.scrollTo({ top: target, behavior: "smooth" });
1682
+ } else {
1683
+ this._clearScrollingTo();
1684
+ this.element.scrollTop = target;
1685
+ }
1686
}
1687
1688
reApplyScroll() {
@@ -1727,7 +1770,7 @@ function getNestedContainer(parentStep) {
1770
* Schedule a step to collapse after a delay
1771
* Automatically handles cancellation on click and reset on hover
1772
*/
1730
-function scheduleStepCollapse(stepElement, delayMs) {
1773
+function scheduleStepCollapse(stepElement, delayMs=STEP_COLLAPSE_DELAY.other) {
1774
// skip if any existing timeout for this step
1775
if (stepElement.hasAttribute("data-collapse-timeout-id")) return;
1776
// skip already collapsed steps