From 3321fce4facf8e516f521c5a9fe18d0e9ffd975d Mon Sep 17 00:00:00 2001 From: dwrz Date: Tue, 9 Jun 2026 18:50:25 +0000 Subject: [PATCH] Add scroll button and auto-scroll --- internal/service/static/icons.svg | 10 ++ internal/service/static/main.css | 5 + internal/service/static/main.js | 124 +++++++++++++++++- .../templates/static/footer/toolbar.gohtml | 9 ++ 4 files changed, 144 insertions(+), 4 deletions(-) diff --git a/internal/service/static/icons.svg b/internal/service/static/icons.svg index 7bab416..141ec2d 100644 --- a/internal/service/static/icons.svg +++ b/internal/service/static/icons.svg @@ -95,6 +95,16 @@ + + + + + + + + + + diff --git a/internal/service/static/main.css b/internal/service/static/main.css index 04408ea..e558e20 100644 --- a/internal/service/static/main.css +++ b/internal/service/static/main.css @@ -474,6 +474,11 @@ body { height: 1rem; } +.scroll-icon--up { display: inline; } +.scroll-icon--down { display: none; } +#scroll.scrolled-up .scroll-icon--up { display: none; } +#scroll.scrolled-up .scroll-icon--down { display: inline; } + .footer__toolbar-btn--muted { color: var(--color-red); border-color: var(--color-red); diff --git a/internal/service/static/main.js b/internal/service/static/main.js index 559cf8e..3d1c142 100644 --- a/internal/service/static/main.js +++ b/internal/service/static/main.js @@ -29,9 +29,15 @@ class Odidere { this.isRecording = false; this.isMuted = false; this.isResetPending = false; + this.isScrollPending = false; this.isStopPending = false; this.mediaRecorder = null; + // Auto-scroll: enabled by default, disabled when user scrolls up, + // re-enabled when they scroll back to bottom or send a new message. + this.isAutoScrollEnabled = true; + this._lastScrollTop = 0; + // DOM Elements this.$attach = document.getElementById('attach'); this.$attachments = document.getElementById('attachments'); @@ -41,6 +47,7 @@ class Odidere { this.$model = document.getElementById('model'); this.$ptt = document.getElementById('ptt'); this.$reset = document.getElementById('reset'); + this.$scroll = document.getElementById('scroll'); this.$send = document.getElementById('send'); this.$textInput = document.getElementById('text-input'); this.$voice = document.getElementById('voice'); @@ -97,6 +104,8 @@ class Odidere { this.#setLoadingState(false); this.history = []; + this.isAutoScrollEnabled = true; + this._lastScrollTop = 0; localStorage.removeItem(STORAGE_KEY); this.$chat.innerHTML = ''; this.#clearAttachments(); @@ -151,7 +160,12 @@ class Odidere { this.$reset.addEventListener('click', () => this.#handleResetClick()); this.document.addEventListener('click', (e) => this.#handleResetCancel(e)); - // File attachment + // Scroll button: two-press safety, swaps between scroll-to-bottom and + // scroll-to-top. + this.$scroll.addEventListener('click', () => this.#handleScrollClick()); + this.document.addEventListener('click', (e) => this.#handleScrollCancel(e)); + + // File attachment. this.$attach.addEventListener('click', () => this.$fileInput.click()); this.$fileInput.addEventListener('change', (e) => this.#handleAttachments(e.target.files), @@ -167,6 +181,17 @@ class Odidere { // Mute button this.$mute.addEventListener('click', () => this.#toggleMute()); + // Scroll button: update icon as user scrolls + this.$chat.addEventListener( + 'scroll', + () => { + this.#updateScrollIcon(); + this.#handleChatScroll(); + }, + { passive: true }, + ); + this.#updateScrollIcon(); + // Settings modal this.$settings.addEventListener('click', () => this.openSettings()); this.$settingsClose.addEventListener('click', () => this.closeSettings()); @@ -359,6 +384,95 @@ class Odidere { this.$reset.classList.remove('footer__toolbar-btn--pending'); } + /** + * #handleScrollClick toggles the pending scroll state on first press, + * executes the scroll on second press. At bottom → scroll to top, + * elsewhere → scroll to bottom. + */ + #handleScrollClick() { + if (this.isScrollPending) { + this.isScrollPending = false; + this.$scroll.classList.remove('footer__toolbar-btn--pending'); + if (this.#isAtBottom()) { + this.$chat.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + this.$chat.scrollTo({ + top: this.$chat.scrollHeight, + behavior: 'smooth', + }); + } + this.#updateScrollIcon(); + } else { + this.isScrollPending = true; + this.$scroll.classList.add('footer__toolbar-btn--pending'); + } + } + + /** + * #handleScrollCancel clears the pending scroll state when the user + * clicks anywhere outside the scroll button. + * @param {MouseEvent} event + */ + #handleScrollCancel(event) { + if (!this.isScrollPending) return; + if (this.$scroll.contains(event.target)) return; + this.isScrollPending = false; + this.$scroll.classList.remove('footer__toolbar-btn--pending'); + } + + /** + * #handleChatScroll detects whether the user scrolled up or down and + * toggles auto-scroll accordingly. Scrolling up disables it; scrolling + * back to the bottom re-enables it. + */ + #handleChatScroll() { + const { scrollTop, scrollHeight, clientHeight } = this.$chat; + const distanceFromBottom = scrollHeight - clientHeight - scrollTop; + const isAtBottom = distanceFromBottom < 10; + + if (scrollTop < this._lastScrollTop && !isAtBottom) { + // User scrolled up and is not at the bottom → disable auto-scroll. + this.isAutoScrollEnabled = false; + } else if (isAtBottom) { + // User scrolled back to the bottom → re-enable auto-scroll. + this.isAutoScrollEnabled = true; + } + + this._lastScrollTop = scrollTop; + } + + /** + * #isAtBottom checks if the chat is scrolled within 10px of the bottom. + * @returns {boolean} + */ + #isAtBottom() { + const { scrollTop, scrollHeight, clientHeight } = this.$chat; + return scrollHeight - clientHeight - scrollTop < 10; + } + + /** + * #updateScrollIcon toggles a CSS class to show the appropriate arrow + * and updates the aria-label. No DOM nodes are created on scroll. + */ + #updateScrollIcon() { + const atBottom = this.#isAtBottom(); + this.$scroll.classList.toggle('scrolled-up', !atBottom); + this.$scroll.setAttribute( + 'aria-label', + atBottom ? 'Scroll to top' : 'Scroll to bottom', + ); + } + + /** + * #scrollToBottom scrolls the chat to the bottom if auto-scroll is + * enabled (i.e., the user is following along and hasn't scrolled up). + */ + #scrollToBottom() { + if (this.isAutoScrollEnabled) { + this.$chat.scrollTop = this.$chat.scrollHeight; + } + } + /** * #handleSendClick routes to either send or stop based on loading state. */ @@ -651,6 +765,8 @@ class Odidere { */ async #sendRequest({ audio = null, text = '' }) { this.#setLoadingState(true); + // Re-enable auto-scroll when the user sends a new message. + this.isAutoScrollEnabled = true; this.currentController = new AbortController(); try { @@ -1296,7 +1412,7 @@ class Odidere { ); this.$chat.appendChild($msg); - this.$chat.scrollTop = this.$chat.scrollHeight; + this.#scrollToBottom(); } /** @@ -1372,7 +1488,7 @@ class Odidere { ); this.$chat.appendChild($msg); - this.$chat.scrollTop = this.$chat.scrollHeight; + this.#scrollToBottom(); } /** @@ -1385,7 +1501,7 @@ class Odidere { $msg.querySelector('.message__content').textContent = message; this.$chat.appendChild($msg); - this.$chat.scrollTop = this.$chat.scrollHeight; + this.#scrollToBottom(); } // ==================== diff --git a/internal/service/templates/static/footer/toolbar.gohtml b/internal/service/templates/static/footer/toolbar.gohtml index f60c106..ab4f9da 100644 --- a/internal/service/templates/static/footer/toolbar.gohtml +++ b/internal/service/templates/static/footer/toolbar.gohtml @@ -17,6 +17,15 @@ > +