Refactor scroll behavior
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user