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