Refactor scroll behavior

This commit is contained in:
dwrz
2026-07-06 17:22:32 +00:00
parent cc0d1d9892
commit d1156d9b7c
4 changed files with 81 additions and 69 deletions

View File

@@ -98,6 +98,7 @@ body {
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
padding: var(--s1); padding: var(--s1);
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border-light); border: 1px solid var(--color-border-light);
@@ -399,17 +400,17 @@ body {
height: 1rem; height: 1rem;
} }
.scroll-icon--up { /* Following state: cyan accent on the down button. */
display: inline; #scroll-down.following {
color: var(--color-cyan);
border-color: var(--color-cyan);
} }
.scroll-icon--down {
display: none; @media (hover: hover) {
} #scroll-down.following:hover {
#scroll.scrolled-up .scroll-icon--up { color: var(--color-cyan);
display: none; border-color: var(--color-cyan);
} }
#scroll.scrolled-up .scroll-icon--down {
display: inline;
} }
.footer__toolbar-btn--muted { .footer__toolbar-btn--muted {

View File

@@ -76,7 +76,6 @@ class Odidere {
this.isTranscribing = false; this.isTranscribing = false;
this.isMuted = false; this.isMuted = false;
this.isResetPending = false; this.isResetPending = false;
this.isScrollPending = false;
this.isStopPending = false; this.isStopPending = false;
this.pendingDeleteId = null; this.pendingDeleteId = null;
// Promise for the currently in-flight runTurn, so reset() can await it. // Promise for the currently in-flight runTurn, so reset() can await it.
@@ -173,7 +172,7 @@ class Odidere {
} }
this.pendingDeleteId = null; this.pendingDeleteId = null;
this.conversation.clear(); this.conversation.clear();
this.renderer.setAutoScroll(true); this.renderer.setAutoScroll(false);
this.renderer.clearChat(); this.renderer.clearChat();
this.systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY) || ''; this.systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY) || '';
this.renderer.renderSystemMessage(this.systemMessage); this.renderer.renderSystemMessage(this.systemMessage);
@@ -232,13 +231,10 @@ class Odidere {
// --- Chat scroll --- // --- Chat scroll ---
this.renderer.$chat.addEventListener( this.renderer.$chat.addEventListener(
'scroll', 'scroll',
() => { () => this.renderer.handleChatScroll(),
this.renderer.updateScrollIcon();
this.renderer.handleChatScroll();
},
{ passive: true }, { passive: true },
); );
this.renderer.updateScrollIcon(); this.renderer.updateFollowIndicator();
// --- Settings modal --- // --- Settings modal ---
this.$settings.addEventListener('click', () => this.settings.open()); this.$settings.addEventListener('click', () => this.settings.open());
@@ -309,7 +305,6 @@ class Odidere {
// Cancel pending states for other actions. // Cancel pending states for other actions.
if (action !== 'reset') this._cancelResetPending(); if (action !== 'reset') this._cancelResetPending();
if (action !== 'send') this._cancelStopPending(); if (action !== 'send') this._cancelStopPending();
if (action !== 'scroll') this._cancelScrollPending();
if (action !== 'delete') this._cancelDeletePending(); if (action !== 'delete') this._cancelDeletePending();
this.handleAction(action, event, $target); this.handleAction(action, event, $target);
@@ -323,8 +318,11 @@ class Odidere {
case 'reset': case 'reset':
this._handleResetClick(); this._handleResetClick();
break; break;
case 'scroll': case 'scroll-up':
this._handleScrollClick(); this._handleScrollUp();
break;
case 'scroll-down':
this._handleScrollDown();
break; break;
case 'mute': case 'mute':
this.toggleMute(); this.toggleMute();
@@ -591,7 +589,8 @@ class Odidere {
*/ */
async runTurn({ text = '', detectedLanguage = '', voice } = {}) { async runTurn({ text = '', detectedLanguage = '', voice } = {}) {
this.setLoadingState(true); 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.currentController = new AbortController();
this._currentRun = (async () => { this._currentRun = (async () => {
@@ -653,6 +652,13 @@ class Odidere {
if (voice) meta.voice = voice; if (voice) meta.voice = voice;
const appended = this.conversation.append([userMessage], meta); const appended = this.conversation.append([userMessage], meta);
this.renderer.renderMessages(appended, 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, { const res = await fetch(STREAM_ENDPOINT, {
@@ -1227,27 +1233,18 @@ class Odidere {
this.$reset.classList.remove('footer__toolbar-btn--pending'); this.$reset.classList.remove('footer__toolbar-btn--pending');
} }
_handleScrollClick() { _handleScrollUp() {
if (this.isScrollPending) { const $chat = this.renderer.$chat;
this.isScrollPending = false; this.renderer.setAutoScroll(false);
this.$scroll.classList.remove('footer__toolbar-btn--pending'); $chat.scrollTop = 0;
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');
}
} }
_cancelScrollPending() { _handleScrollDown() {
if (!this.isScrollPending) return; const $chat = this.renderer.$chat;
this.isScrollPending = false; // Set scrollTop first so the queued scroll event sees bottom position
this.$scroll.classList.remove('footer__toolbar-btn--pending'); // and does not disable following.
$chat.scrollTop = $chat.scrollHeight;
this.renderer.setAutoScroll(true);
} }
_handleDelete(event, messageId, field, toolCallId, $btn) { _handleDelete(event, messageId, field, toolCallId, $btn) {
@@ -1345,7 +1342,6 @@ class Odidere {
_cancelAllPending() { _cancelAllPending() {
this._cancelResetPending(); this._cancelResetPending();
this._cancelStopPending(); this._cancelStopPending();
this._cancelScrollPending();
this._cancelDeletePending(); this._cancelDeletePending();
} }

View File

@@ -22,12 +22,12 @@ export class Renderer {
this.$chat = document.getElementById('chat'); this.$chat = document.getElementById('chat');
this.$chatLoading = document.getElementById('chat-loading'); this.$chatLoading = document.getElementById('chat-loading');
this.$attachments = document.getElementById('attachments'); this.$attachments = document.getElementById('attachments');
this.$scroll = document.getElementById('scroll'); this.$scrollDown = document.getElementById('scroll-down');
this.$voice = document.getElementById('voice'); this.$voice = document.getElementById('voice');
// Auto-scroll: enabled by default, disabled when user scrolls up. // Auto-scroll follow state. Following is opt-in, so it starts off; the
this.isAutoScrollEnabled = true; // user engages it via the scroll button.
this._lastScrollTop = 0; this.isAutoScrollEnabled = false;
// Templates // Templates
this.$tplAssistantMessage = document.getElementById( this.$tplAssistantMessage = document.getElementById(
@@ -427,36 +427,43 @@ export class Renderer {
} }
} }
isAtBottom() { // Anchor the last user message to the top of the chat viewport. Uses direct
const { scrollTop, scrollHeight, clientHeight } = this.$chat; // scrollTop math (not scrollIntoView) to avoid moving ancestors/footer on
return scrollHeight - clientHeight - scrollTop < 10; // 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() { // Icon and label are driven by follow state, not scroll position, so they
const atBottom = this.isAtBottom(); // never drift from isAutoScrollEnabled.
this.$scroll.classList.toggle('scrolled-up', !atBottom); updateFollowIndicator() {
this.$scroll.setAttribute( this.$scrollDown.classList.toggle('following', this.isAutoScrollEnabled);
'aria-label',
atBottom ? 'Scroll to top' : 'Scroll to bottom',
);
} }
// 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() { handleChatScroll() {
const { scrollTop, scrollHeight, clientHeight } = this.$chat; const { scrollHeight, scrollTop, clientHeight } = this.$chat;
const distanceFromBottom = scrollHeight - clientHeight - scrollTop; if (scrollHeight - scrollTop - clientHeight > 100) {
const isAtBottom = distanceFromBottom < 10; this.setAutoScroll(false);
if (scrollTop < this._lastScrollTop && !isAtBottom) {
this.isAutoScrollEnabled = false;
} else if (isAtBottom) {
this.isAutoScrollEnabled = true;
} }
this._lastScrollTop = scrollTop;
} }
// Set the flag and sync the icon so the two never drift.
setAutoScroll(enabled) { setAutoScroll(enabled) {
this.isAutoScrollEnabled = enabled; this.isAutoScrollEnabled = enabled;
this.updateFollowIndicator();
} }
clearChat() { clearChat() {

View File

@@ -23,12 +23,20 @@
<button <button
type="button" type="button"
class="footer__toolbar-btn" class="footer__toolbar-btn"
id="scroll" id="scroll-up"
data-action="scroll" data-action="scroll-up"
aria-label="Scroll to top" aria-label="Scroll to top"
> >
<svg class="icon scroll-icon--up"><use href="/static/icons.svg#scroll-up"></use></svg> <svg class="icon"><use href="/static/icons.svg#scroll-up"></use></svg>
<svg class="icon scroll-icon--down"><use href="/static/icons.svg#scroll-down"></use></svg> </button>
<button
type="button"
class="footer__toolbar-btn"
id="scroll-down"
data-action="scroll-down"
aria-label="Scroll to bottom and follow"
>
<svg class="icon"><use href="/static/icons.svg#scroll-down"></use></svg>
</button> </button>
<button <button
type="button" type="button"