scroller polishing, icon tooltips

frdel committed Jan 30, 2026 at 21:53 UTC d20d9e776b44176a574c11d1e7bec64be180072a
4 files changed +179 -85
webui/components/chat/message-queue/message-queue-store.js
+2
@@ -1,4 +1,5 @@
1 import { createStore } from "/js/AlpineStore.js";
2 +import { store as navStore } from "/components/chat/navigation/chat-navigation-store.js";
3 import * as api from "/js/api.js";
4
5 const model = {
@@ -71,6 +72,7 @@ const model = {
72 const context = globalThis.getContext?.();
73 if (!context || !this.hasQueue) return;
74 try {
75 + navStore.scrollToBottom();
76 await api.callJsonApi("/message_queue_send", { context, send_all: true });
77 } catch (e) {
78 console.error("Failed to send all queued:", e);
webui/components/messages/process-group/process-group.css
+1
@@ -659,6 +659,7 @@
659 padding: var(--spacing-xs);
660 background: rgba(0, 0, 0, 0.8);
661 min-height: 5em;
662 + min-width: 20em;
663 width: max-content;
664 border-radius: var(--border-radius-sm);
665 color: #c9d1d9;
webui/js/messages.js
+32 -85
@@ -11,6 +11,7 @@ import {
11 import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
12 import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
13 import { formatDuration } from "./time-utils.js";
14 +import { Scroller } from "./scroller.js";
15
16 // Delay before collapsing previous steps when a new step is added
17 const STEP_COLLAPSE_DELAY = {
@@ -74,7 +75,7 @@ export function setMessages(messages) {
75 const cutoff = isLargeAppend ? Math.max(0, messages.length - 2) : 0;
76 const massRender = historyEmpty || isLargeAppend;
77
77 - const mainScroller = new Scroller(history, { smooth: !massRender, toleranceRem: 6 });
78 + const mainScroller = new Scroller(history, { smooth: !massRender, toleranceRem: 4, reapplyDelayMs: 1000 });
79
80 // process messages
81 for (let i = 0; i < messages.length; i++) {
@@ -667,8 +668,8 @@ export function drawMessageAgent({
668 }) {
669 const title = cleanStepTitle(heading);
670 let displayKvps = {};
670 - if (kvps?.thoughts) displayKvps["icon://lightbulb"] = kvps.thoughts;
671 - if (kvps?.step) displayKvps["icon://step"] = kvps.step;
671 + if (kvps?.thoughts) displayKvps["icon://lightbulb[Thoughts]"] = kvps.thoughts;
672 + if (kvps?.step) displayKvps["icon://step[Step]"] = kvps.step;
673 const thoughtsText = String(kvps?.thoughts ?? "");
674 const headerLabels = [
675 kvps?.tool_name && { label: kvps.tool_name, class: "tool-name-badge" },
@@ -1380,9 +1381,9 @@ function drawKvpsIncremental(container, kvps, latex) {
1381 th = row.insertCell(0);
1382 th.classList.add("kvps-key");
1383 }
1383 - const iconName = extractIconFromKey(key);
1384 - if (iconName) {
1385 - th.innerHTML = `<span class="material-symbols-outlined">${iconName}</span>`;
1384 + const convertedKey = convertIcons(String(key), "");
1385 + if (convertedKey !== String(key)) {
1386 + th.innerHTML = convertedKey;
1387 } else {
1388 th.textContent = convertToTitleCase(key);
1389 }
@@ -1499,12 +1500,6 @@ function convertFilePaths(str) {
1500 return str.replace(/file:\/\//g, "/download_work_dir_file?path=");
1501 }
1502
1502 -export function convertIcons(str) {
1503 - return str.replace(
1504 - /icon:\/\/([a-zA-Z0-9_]+)/g,
1505 - '<span class="icon material-symbols-outlined">$1</span>',
1506 - );
1507 -}
1503
1504 function escapeHTML(str) {
1505 const escapeChars = {
@@ -1624,72 +1619,6 @@ function adjustMarkdownRender(element) {
1619 });
1620 }
1621
1627 -export class Scroller {
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 -
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() {
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() {
1689 - if (this.wasAtBottom && !this.isAtBottom()) this.scrollToBottom();
1690 - }
1691 -}
1692 -
1622 /**
1623 * Create a new collapsible process group
1624 */
@@ -1893,12 +1822,30 @@ function getStepTitle(heading, kvps, type) {
1822 }
1823
1824 /**
1896 - * Extract icon name from a key with icon:// prefix
1825 + * Convert icon://name[Optional Tooltip] into a material icon span.
1826 + * Tooltip supports escaped brackets inside, e.g. [Tooltip of \[brackets\]].
1827 */
1898 -function extractIconFromKey(key) {
1899 - if (!key) return null;
1900 - const match = String(key).match(/^icon:\/\/([a-zA-Z0-9_]+)/);
1901 - return match ? match[1] : null;
1828 +export function convertIcons(html, classes = "") {
1829 + if (html == null) return "";
1830 +
1831 + return String(html).replace(
1832 + /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g,
1833 + (match, iconName, tooltipBlock) => {
1834 + if (!tooltipBlock) {
1835 + return `<span class="icon material-symbols-outlined ${classes}">${iconName}</span>`;
1836 + }
1837 +
1838 + const tooltipRaw = tooltipBlock
1839 + .slice(1, -1)
1840 + .replace(/\\\[/g, "[")
1841 + .replace(/\\\]/g, "]")
1842 + .replace(/\\\\/g, "\\");
1843 +
1844 + const tooltip = escapeHTML(tooltipRaw);
1845 +
1846 + return `<span class="icon material-symbols-outlined ${classes}" title="${tooltip}" data-bs-placement="top" data-bs-trigger="hover">${iconName}</span>`;
1847 + },
1848 + );
1849 }
1850
1851 /**
@@ -1909,8 +1856,8 @@ function cleanStepTitle(text, maxLength = 100) {
1856 if (!text) return "";
1857 let cleaned = String(text);
1858
1912 - // Remove icon:// patterns (e.g., "icon://network_intelligence")
1913 - cleaned = cleaned.replace(/icon:\/\/[a-zA-Z0-9_]+\s*/g, "");
1859 + // Remove icon:// patterns (e.g., "icon://network_intelligence" or "icon://network_intelligence[Tooltip]")
1860 + cleaned = cleaned.replace(/icon:\/\/[a-zA-Z0-9_]+(\[(?:\\.|[^\]])*\])?\s*/g, "");
1861
1862 // Trim whitespace
1863 cleaned = cleaned.trim();
webui/js/scroller.js new
+144
@@ -0,0 +1,144 @@
1 +export class Scroller {
2 + constructor(
3 + element,
4 + {
5 + smooth = false,
6 + toleranceRem = 2,
7 + reapplyDelayMs = 0,
8 + reapplyToleranceRatio = 0,
9 + } = {},
10 + ) {
11 + this.element = element;
12 + this.smooth = smooth;
13 + this.reapplyDelayMs = reapplyDelayMs;
14 + this.reapplyToleranceRatio = reapplyToleranceRatio;
15 + this.tolerance =
16 + toleranceRem *
17 + parseFloat(getComputedStyle(document.documentElement).fontSize);
18 + this.wasAtBottom = this.isAtBottom();
19 + this._scrollListener = null;
20 + }
21 +
22 + _getEffectiveScrollTop() {
23 + const scrollingToRaw = this.element?.dataset?.scrollingTo;
24 + const scrollingTo = scrollingToRaw == null ? null : Number(scrollingToRaw);
25 + if (Number.isFinite(scrollingTo)) return scrollingTo;
26 + return this.element.scrollTop;
27 + }
28 +
29 + _setScrollingTo(target) {
30 + this.element.dataset.scrollingTo = String(target);
31 +
32 + if (this._scrollListener) return;
33 +
34 + this._scrollListener = () => {
35 + const current = this.element.scrollTop;
36 + const activeTargetRaw = this.element?.dataset?.scrollingTo;
37 + const activeTarget = activeTargetRaw == null ? null : Number(activeTargetRaw);
38 + if (!Number.isFinite(activeTarget)) {
39 + this._clearScrollingTo();
40 + return;
41 + }
42 +
43 + if (current >= activeTarget - 1) this._clearScrollingTo();
44 + };
45 +
46 + this.element.addEventListener("scroll", this._scrollListener, {
47 + passive: true,
48 + });
49 + }
50 +
51 + _clearScrollingTo() {
52 + delete this.element.dataset.scrollingTo;
53 + if (this._scrollListener) {
54 + this.element.removeEventListener("scroll", this._scrollListener);
55 + this._scrollListener = null;
56 + }
57 + }
58 +
59 + isAtBottom() {
60 + const { scrollHeight, clientHeight } = this.element;
61 + const scrollTop = this._getEffectiveScrollTop();
62 + return scrollHeight - scrollTop - clientHeight <= this.tolerance;
63 + }
64 +
65 + _getBottomDistancePx() {
66 + const { scrollHeight, clientHeight } = this.element;
67 + const scrollTop = this._getEffectiveScrollTop();
68 + return scrollHeight - scrollTop - clientHeight;
69 + }
70 +
71 + scrollToBottom() {
72 + const target = Math.max(
73 + 0,
74 + this.element.scrollHeight - this.element.clientHeight,
75 + );
76 + if (this.smooth) {
77 + this._setScrollingTo(target);
78 + this.element.scrollTo({ top: target, behavior: "smooth" });
79 + } else {
80 + this._clearScrollingTo();
81 + this.element.scrollTop = target;
82 + }
83 + }
84 +
85 + _getReapplyTimeoutId() {
86 + const raw = this.element?.dataset?.scrollerTimeout;
87 + const parsed = raw == null ? null : Number(raw);
88 + if (!Number.isFinite(parsed)) return null;
89 + return parsed;
90 + }
91 +
92 + _clearReapplyTimeout() {
93 + const id = this._getReapplyTimeoutId();
94 + if (id != null) clearTimeout(id);
95 + delete this.element.dataset.scrollerTimeout;
96 + }
97 +
98 + _scheduleReapplyScrollToBottom() {
99 + this._clearReapplyTimeout();
100 +
101 + const id = setTimeout(() => {
102 + delete this.element.dataset.scrollerTimeout;
103 +
104 + if (!this.wasAtBottom) return;
105 + if (!this.isAtBottom()) return;
106 +
107 + this.scrollToBottom();
108 + }, this.reapplyDelayMs);
109 +
110 + this.element.dataset.scrollerTimeout = String(id);
111 + }
112 +
113 + reApplyScroll(instant = false) {
114 + this._clearReapplyTimeout();
115 +
116 + if (!this.wasAtBottom) return;
117 +
118 + if (instant) {
119 + this.scrollToBottom();
120 + return;
121 + }
122 +
123 + if (!this.isAtBottom()) {
124 + this.scrollToBottom();
125 + return;
126 + }
127 +
128 + const ratio = this.reapplyToleranceRatio;
129 + if (ratio <= 0) {
130 + if (this.reapplyDelayMs > 0) this._scheduleReapplyScrollToBottom();
131 + else this.scrollToBottom();
132 + return;
133 + }
134 +
135 + const dist = this._getBottomDistancePx();
136 + const tolerance = this.tolerance;
137 + const clampedDist = Math.max(0, Math.min(dist, tolerance));
138 + const progress = tolerance > 0 ? 1 - clampedDist / tolerance : 1;
139 + if (progress < ratio) return;
140 +
141 + if (this.reapplyDelayMs > 0) this._scheduleReapplyScrollToBottom();
142 + else this.scrollToBottom();
143 + }
144 +}