diff --git a/internal/service/static/main.css b/internal/service/static/main.css
index 8d155f8..8e0b8bb 100644
--- a/internal/service/static/main.css
+++ b/internal/service/static/main.css
@@ -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 {
diff --git a/internal/service/static/main.js b/internal/service/static/main.js
index 76b61b9..1f4fff3 100644
--- a/internal/service/static/main.js
+++ b/internal/service/static/main.js
@@ -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();
}
diff --git a/internal/service/static/render.js b/internal/service/static/render.js
index 0508834..d293d2b 100644
--- a/internal/service/static/render.js
+++ b/internal/service/static/render.js
@@ -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() {
diff --git a/internal/service/templates/static/footer/toolbar.gohtml b/internal/service/templates/static/footer/toolbar.gohtml
index ac95e5a..f738e22 100644
--- a/internal/service/templates/static/footer/toolbar.gohtml
+++ b/internal/service/templates/static/footer/toolbar.gohtml
@@ -23,12 +23,20 @@
+