scrollChatToBottom; chat navigation helpers
cleanup and mv to chat-navigation-store chat navigation btn styling
3clyp50 committed
Jan 15, 2026 at 14:15 UTC
1455e1d213237548b63c884149c218698e39b075
4 files changed
+151
-5
webui/components/chat/input/progress.html
+27
-4
@@ -2,6 +2,7 @@
2
<head>
3
<script type="module">
4
import { store as speechStore } from "/components/chat/speech/speech-store.js";
5
+ import { store as chatNavStore } from "/components/chat/navigation/chat-navigation-store.js";
6
</script>
7
</head>
8
<body>
@@ -9,17 +10,39 @@
10
<h4 id="progress-bar-h">
11
<span id="progress-bar-i">|></span><span id="progress-bar"></span>
12
</h4>
12
- <h4 id="progress-bar-stop-speech" x-data x-cloak x-show="$store.speech.isSpeaking">
13
- <span id="stop-speech" @click="$store.speech.stop()" style="cursor: pointer">Stop Speech</span>
14
- </h4>
13
+ <div id="progress-bar-right">
14
+ <div id="chat-nav-buttons" aria-label="Chat navigation">
15
+ <button class="btn-icon-action" title="Scroll to top" x-on:click="$store.chatNavigation.scrollToTop()">
16
+ <span class="material-symbols-outlined">vertical_align_top</span>
17
+ </button>
18
+ <button class="btn-icon-action" title="Previous user message" x-on:click="$store.chatNavigation.scrollToPrevUserMessage()">
19
+ <span class="material-symbols-outlined">keyboard_arrow_up</span>
20
+ </button>
21
+ <button class="btn-icon-action" title="Next user message" x-on:click="$store.chatNavigation.scrollToNextUserMessage()">
22
+ <span class="material-symbols-outlined">keyboard_arrow_down</span>
23
+ </button>
24
+ <button class="btn-icon-action" title="Scroll to bottom" x-on:click="$store.chatNavigation.scrollToBottom()">
25
+ <span class="material-symbols-outlined">vertical_align_bottom</span>
26
+ </button>
27
+ </div>
28
+ <h4 id="progress-bar-stop-speech" x-data x-cloak x-show="$store.speech.isSpeaking">
29
+ <span id="stop-speech" @click="$store.speech.stop()" style="cursor: pointer">Stop Speech</span>
30
+ </h4>
31
+ </div>
32
</div>
33
34
35
<style>
19
- #progress-bar-box { background-color: var(--color-panel); padding: var(--spacing-sm) var(--spacing-md); padding-bottom: 0; display: flex; justify-content: space-between; z-index: 1001; }
36
+ #progress-bar-box { background-color: var(--color-panel); padding: var(--spacing-sm) var(--spacing-md); padding-bottom: 0; display: flex; justify-content: space-between; z-index: 1001; gap: var(--spacing-sm); }
37
#progress-bar-box h4 { margin: 0 1.2em 0 0; }
38
#progress-bar-h { color: var(--color-primary); display: flex; align-items: left; justify-content: flex-start; height: 1.2em; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; font-weight: normal; }
39
#progress-bar-i { font-weight: bold; padding-right: 0.5em; color: var(--color-secondary); }
40
+ #progress-bar-right { display: flex; align-items: center; gap: var(--spacing-sm); flex-shrink: 0; }
41
+ #chat-nav-buttons { display: flex; align-items: center; gap: 2px; opacity: 0.85; }
42
+ #chat-nav-buttons .btn-icon-action { padding: var(--spacing-xs); }
43
+ #chat-nav-buttons .btn-icon-action {
44
+ border: none !important;
45
+ }
46
.shiny-text { background: linear-gradient(to right, var(--color-primary-dark) 20%, var(--color-text) 40%, var(--color-text) 60%, var(--color-primary-dark) 60%); background-size: 200% auto; color: transparent; -webkit-background-clip: text; background-clip: text; animation: shine 1s linear infinite; }
47
@keyframes shine { to { background-position: -200% center; } }
48
.light-mode #progress-bar-i { color: var(--color-border-dark); opacity: 0.5; }
webui/components/chat/navigation/chat-navigation-store.js
new
+100
@@ -0,0 +1,100 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+
3
+const model = {
4
+ // Configuration
5
+ scrollMargin: 60,
6
+ prevTolerance: 35,
7
+ nextTolerance: 5,
8
+
9
+ init() {
10
+ // Any initialization if needed
11
+ },
12
+
13
+ scrollToTop() {
14
+ const scroller = this._getChatHistoryEl();
15
+ if (scroller) scroller.scrollTo({ top: 0, behavior: "instant" });
16
+ },
17
+
18
+ scrollToBottom() {
19
+ if (globalThis.forceScrollChatToBottom) {
20
+ globalThis.forceScrollChatToBottom();
21
+ } else {
22
+ const scroller = this._getChatHistoryEl();
23
+ if (scroller) scroller.scrollTop = scroller.scrollHeight;
24
+ }
25
+ },
26
+
27
+ scrollToPrevUserMessage() {
28
+ const scroller = this._getChatHistoryEl();
29
+ if (!scroller) return;
30
+
31
+ const positions = this._getUserMessagePositions(scroller);
32
+ const scrollerRect = scroller.getBoundingClientRect();
33
+
34
+ const prevThreshold = this.scrollMargin - this.prevTolerance; // 25px
35
+
36
+ const currentIndex = positions.findIndex((p) => {
37
+ const relativeTop = p.el.getBoundingClientRect().top - scrollerRect.top;
38
+ return relativeTop >= prevThreshold;
39
+ });
40
+
41
+ if (currentIndex > 0) {
42
+ // Go to previous message
43
+ positions[currentIndex - 1].el.scrollIntoView({ block: "start" });
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" });
50
+ }
51
+ },
52
+
53
+ scrollToNextUserMessage() {
54
+ const scroller = this._getChatHistoryEl();
55
+ if (!scroller) return;
56
+
57
+ const positions = this._getUserMessagePositions(scroller);
58
+ const scrollerRect = scroller.getBoundingClientRect();
59
+
60
+ const nextThreshold = this.scrollMargin + this.nextTolerance; // 65px
61
+
62
+ // Find first message below the threshold
63
+ const targetIndex = positions.findIndex((p) => {
64
+ const relativeTop = p.el.getBoundingClientRect().top - scrollerRect.top;
65
+ return relativeTop > nextThreshold;
66
+ });
67
+
68
+ if (targetIndex !== -1) {
69
+ // Go to that message
70
+ positions[targetIndex].el.scrollIntoView({ block: "start" });
71
+ } else {
72
+ // No message found below threshold => scroll to bottom
73
+ this.scrollToBottom();
74
+ }
75
+ },
76
+
77
+ // Helpers
78
+ _getChatHistoryEl() {
79
+ return document.getElementById("chat-history");
80
+ },
81
+
82
+ _getUserMessagePositions(scroller) {
83
+ const userMessageEls = Array.from(
84
+ scroller.querySelectorAll(".message-container.user-container")
85
+ );
86
+ // Helper for getElementTopInScroller
87
+ const getTop = (el) => {
88
+ const elRect = el.getBoundingClientRect();
89
+ const scrollerRect = scroller.getBoundingClientRect();
90
+ return elRect.top - scrollerRect.top + scroller.scrollTop;
91
+ };
92
+
93
+ return userMessageEls
94
+ .map((el) => ({ el, top: getTop(el) }))
95
+ .sort((a, b) => a.top - b.top);
96
+ }
97
+};
98
+
99
+const store = createStore("chatNavigation", model);
100
+export { store };
webui/css/messages.css
+1
@@ -667,6 +667,7 @@
667
/* margin-bottom: var(--spacing-sm); */
668
width: 100%;
669
max-width: 100%;
670
+ scroll-margin-top: 60px; /* Ensure nice spacing when scrolling into view */
671
}
672
673
/* User messages appear instantly */
webui/index.js
+23
-1
@@ -46,6 +46,9 @@ export async function sendMessage() {
46
const hasAttachments = attachmentsWithUrls.length > 0;
47
48
if (message || hasAttachments) {
49
+ // Sending a message is an explicit user intent to go to the bottom
50
+ forceScrollChatToBottom();
51
+
52
let response;
53
const messageId = generateGUID();
54
@@ -112,6 +115,17 @@ export async function sendMessage() {
115
}
116
globalThis.sendMessage = sendMessage;
117
118
+function getChatHistoryEl() {
119
+ return document.getElementById("chat-history");
120
+}
121
+
122
+function forceScrollChatToBottom() {
123
+ const chatHistoryEl = getChatHistoryEl();
124
+ if (!chatHistoryEl) return;
125
+ chatHistoryEl.scrollTop = chatHistoryEl.scrollHeight;
126
+}
127
+globalThis.forceScrollChatToBottom = forceScrollChatToBottom;
128
+
129
export function toastFetchError(text, error) {
130
console.error(text, error);
131
// Use new frontend error notification system (async, but we don't need to wait)
@@ -569,7 +583,8 @@ function scrollChanged(isAtBottom) {
583
export function updateAfterScroll() {
584
// const toleranceEm = 1; // Tolerance in em units
585
// const tolerancePx = toleranceEm * parseFloat(getComputedStyle(document.documentElement).fontSize); // Convert em to pixels
572
- const tolerancePx = 10;
586
+ // Larger trigger zone near bottom for autoscroll
587
+ const tolerancePx = 80;
588
const chatHistory = document.getElementById("chat-history");
589
if (!chatHistory) return;
590
@@ -581,6 +596,13 @@ export function updateAfterScroll() {
596
}
597
globalThis.updateAfterScroll = updateAfterScroll;
598
599
+import { store as _chatNavigationStore } from "/components/chat/navigation/chat-navigation-store.js";
600
+
601
+
602
+// Navigation logic in chat-navigation-store.js
603
+// forceScrollChatToBottom is kept here as it is used by system events
604
+
605
+
606
// setInterval(poll, 250);
607
608
async function startPolling() {