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() {