From d1156d9b7c9bb4fd2f9b26e3edd216d39ac10c40 Mon Sep 17 00:00:00 2001 From: dwrz Date: Mon, 6 Jul 2026 17:22:32 +0000 Subject: [PATCH] Refactor scroll behavior --- internal/service/static/main.css | 21 +++---- internal/service/static/main.js | 58 +++++++++---------- internal/service/static/render.js | 55 ++++++++++-------- .../templates/static/footer/toolbar.gohtml | 16 +++-- 4 files changed, 81 insertions(+), 69 deletions(-) diff --git a/internal/service/static/main.css b/internal/service/static/main.css index 8d155f8..8e0b8bb 100644 --- a/internal/service/static/main.css +++ b/internal/service/static/main.css @@ -98,6 +98,7 @@ body { flex: 1 1 0; min-height: 0; overflow-y: auto; + overscroll-behavior: contain; padding: var(--s1); background: var(--color-surface); border: 1px solid var(--color-border-light); @@ -399,17 +400,17 @@ body { height: 1rem; } -.scroll-icon--up { - display: inline; +/* Following state: cyan accent on the down button. */ +#scroll-down.following { + color: var(--color-cyan); + border-color: var(--color-cyan); } -.scroll-icon--down { - display: none; -} -#scroll.scrolled-up .scroll-icon--up { - display: none; -} -#scroll.scrolled-up .scroll-icon--down { - display: inline; + +@media (hover: hover) { + #scroll-down.following:hover { + color: var(--color-cyan); + border-color: var(--color-cyan); + } } .footer__toolbar-btn--muted { diff --git a/internal/service/static/main.js b/internal/service/static/main.js index 76b61b9..1f4fff3 100644 --- a/internal/service/static/main.js +++ b/internal/service/static/main.js @@ -76,7 +76,6 @@ class Odidere { this.isTranscribing = false; this.isMuted = false; this.isResetPending = false; - this.isScrollPending = false; this.isStopPending = false; this.pendingDeleteId = null; // Promise for the currently in-flight runTurn, so reset() can await it. @@ -173,7 +172,7 @@ class Odidere { } this.pendingDeleteId = null; this.conversation.clear(); - this.renderer.setAutoScroll(true); + this.renderer.setAutoScroll(false); this.renderer.clearChat(); this.systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY) || ''; this.renderer.renderSystemMessage(this.systemMessage); @@ -232,13 +231,10 @@ class Odidere { // --- Chat scroll --- this.renderer.$chat.addEventListener( 'scroll', - () => { - this.renderer.updateScrollIcon(); - this.renderer.handleChatScroll(); - }, + () => this.renderer.handleChatScroll(), { passive: true }, ); - this.renderer.updateScrollIcon(); + this.renderer.updateFollowIndicator(); // --- Settings modal --- this.$settings.addEventListener('click', () => this.settings.open()); @@ -309,7 +305,6 @@ class Odidere { // Cancel pending states for other actions. if (action !== 'reset') this._cancelResetPending(); if (action !== 'send') this._cancelStopPending(); - if (action !== 'scroll') this._cancelScrollPending(); if (action !== 'delete') this._cancelDeletePending(); this.handleAction(action, event, $target); @@ -323,8 +318,11 @@ class Odidere { case 'reset': this._handleResetClick(); break; - case 'scroll': - this._handleScrollClick(); + case 'scroll-up': + this._handleScrollUp(); + break; + case 'scroll-down': + this._handleScrollDown(); break; case 'mute': this.toggleMute(); @@ -591,7 +589,8 @@ class Odidere { */ async runTurn({ text = '', detectedLanguage = '', voice } = {}) { this.setLoadingState(true); - this.renderer.setAutoScroll(true); + // Follow state is sticky across turns; runTurn does not reset it. It turns + // off only when the user scrolls up or presses the up arrow. Off at load. this.currentController = new AbortController(); this._currentRun = (async () => { @@ -653,6 +652,13 @@ class Odidere { if (voice) meta.voice = voice; const appended = this.conversation.append([userMessage], meta); this.renderer.renderMessages(appended, meta); + // When not following, anchor the new user message to the top so the + // reply grows below it. When following, let streaming pin the bottom. + if (this.renderer.isAutoScrollEnabled) { + this.renderer.scrollToBottom(); + } else { + this.renderer.scrollToLastUserMessage(); + } } const res = await fetch(STREAM_ENDPOINT, { @@ -1227,27 +1233,18 @@ class Odidere { this.$reset.classList.remove('footer__toolbar-btn--pending'); } - _handleScrollClick() { - if (this.isScrollPending) { - this.isScrollPending = false; - this.$scroll.classList.remove('footer__toolbar-btn--pending'); - const $chat = this.renderer.$chat; - if (this.renderer.isAtBottom()) { - $chat.scrollTo({ top: 0, behavior: 'smooth' }); - } else { - $chat.scrollTo({ top: $chat.scrollHeight, behavior: 'smooth' }); - } - this.renderer.updateScrollIcon(); - } else { - this.isScrollPending = true; - this.$scroll.classList.add('footer__toolbar-btn--pending'); - } + _handleScrollUp() { + const $chat = this.renderer.$chat; + this.renderer.setAutoScroll(false); + $chat.scrollTop = 0; } - _cancelScrollPending() { - if (!this.isScrollPending) return; - this.isScrollPending = false; - this.$scroll.classList.remove('footer__toolbar-btn--pending'); + _handleScrollDown() { + const $chat = this.renderer.$chat; + // Set scrollTop first so the queued scroll event sees bottom position + // and does not disable following. + $chat.scrollTop = $chat.scrollHeight; + this.renderer.setAutoScroll(true); } _handleDelete(event, messageId, field, toolCallId, $btn) { @@ -1345,7 +1342,6 @@ class Odidere { _cancelAllPending() { this._cancelResetPending(); this._cancelStopPending(); - this._cancelScrollPending(); this._cancelDeletePending(); } diff --git a/internal/service/static/render.js b/internal/service/static/render.js index 0508834..d293d2b 100644 --- a/internal/service/static/render.js +++ b/internal/service/static/render.js @@ -22,12 +22,12 @@ export class Renderer { this.$chat = document.getElementById('chat'); this.$chatLoading = document.getElementById('chat-loading'); this.$attachments = document.getElementById('attachments'); - this.$scroll = document.getElementById('scroll'); + this.$scrollDown = document.getElementById('scroll-down'); this.$voice = document.getElementById('voice'); - // Auto-scroll: enabled by default, disabled when user scrolls up. - this.isAutoScrollEnabled = true; - this._lastScrollTop = 0; + // Auto-scroll follow state. Following is opt-in, so it starts off; the + // user engages it via the scroll button. + this.isAutoScrollEnabled = false; // Templates this.$tplAssistantMessage = document.getElementById( @@ -427,36 +427,43 @@ export class Renderer { } } - isAtBottom() { - const { scrollTop, scrollHeight, clientHeight } = this.$chat; - return scrollHeight - clientHeight - scrollTop < 10; + // Anchor the last user message to the top of the chat viewport. Uses direct + // scrollTop math (not scrollIntoView) to avoid moving ancestors/footer on + // iOS. No-op if the message is already fully visible. + scrollToLastUserMessage() { + const messages = this.$chat.querySelectorAll('.message--user'); + const $last = messages[messages.length - 1]; + if (!$last) return; + + const { scrollTop, clientHeight } = this.$chat; + const top = $last.offsetTop - this.$chat.offsetTop; + const bottom = top + $last.offsetHeight; + const fullyVisible = top >= scrollTop && bottom <= scrollTop + clientHeight; + if (fullyVisible) return; + + this.$chat.scrollTop = top; } - updateScrollIcon() { - const atBottom = this.isAtBottom(); - this.$scroll.classList.toggle('scrolled-up', !atBottom); - this.$scroll.setAttribute( - 'aria-label', - atBottom ? 'Scroll to top' : 'Scroll to bottom', - ); + // Icon and label are driven by follow state, not scroll position, so they + // never drift from isAutoScrollEnabled. + updateFollowIndicator() { + this.$scrollDown.classList.toggle('following', this.isAutoScrollEnabled); } + // Position-based, disable-only. A user scroll-up past the threshold stops + // following. Never re-enables (a programmatic scroll-to-bottom lands at ~0px, + // so it cannot false-trigger; being near the bottom cannot re-enable). handleChatScroll() { - const { scrollTop, scrollHeight, clientHeight } = this.$chat; - const distanceFromBottom = scrollHeight - clientHeight - scrollTop; - const isAtBottom = distanceFromBottom < 10; - - if (scrollTop < this._lastScrollTop && !isAtBottom) { - this.isAutoScrollEnabled = false; - } else if (isAtBottom) { - this.isAutoScrollEnabled = true; + const { scrollHeight, scrollTop, clientHeight } = this.$chat; + if (scrollHeight - scrollTop - clientHeight > 100) { + this.setAutoScroll(false); } - - this._lastScrollTop = scrollTop; } + // Set the flag and sync the icon so the two never drift. setAutoScroll(enabled) { this.isAutoScrollEnabled = enabled; + this.updateFollowIndicator(); } clearChat() { diff --git a/internal/service/templates/static/footer/toolbar.gohtml b/internal/service/templates/static/footer/toolbar.gohtml index ac95e5a..f738e22 100644 --- a/internal/service/templates/static/footer/toolbar.gohtml +++ b/internal/service/templates/static/footer/toolbar.gohtml @@ -23,12 +23,20 @@ +