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

@@ -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();
}