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