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