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

@@ -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() {