diff --git a/internal/service/static/icons.svg b/internal/service/static/icons.svg
index 7bab416..141ec2d 100644
--- a/internal/service/static/icons.svg
+++ b/internal/service/static/icons.svg
@@ -95,6 +95,16 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/service/static/main.css b/internal/service/static/main.css
index 04408ea..e558e20 100644
--- a/internal/service/static/main.css
+++ b/internal/service/static/main.css
@@ -474,6 +474,11 @@ body {
height: 1rem;
}
+.scroll-icon--up { display: inline; }
+.scroll-icon--down { display: none; }
+#scroll.scrolled-up .scroll-icon--up { display: none; }
+#scroll.scrolled-up .scroll-icon--down { display: inline; }
+
.footer__toolbar-btn--muted {
color: var(--color-red);
border-color: var(--color-red);
diff --git a/internal/service/static/main.js b/internal/service/static/main.js
index 559cf8e..3d1c142 100644
--- a/internal/service/static/main.js
+++ b/internal/service/static/main.js
@@ -29,9 +29,15 @@ class Odidere {
this.isRecording = false;
this.isMuted = false;
this.isResetPending = false;
+ this.isScrollPending = false;
this.isStopPending = false;
this.mediaRecorder = null;
+ // Auto-scroll: enabled by default, disabled when user scrolls up,
+ // re-enabled when they scroll back to bottom or send a new message.
+ this.isAutoScrollEnabled = true;
+ this._lastScrollTop = 0;
+
// DOM Elements
this.$attach = document.getElementById('attach');
this.$attachments = document.getElementById('attachments');
@@ -41,6 +47,7 @@ class Odidere {
this.$model = document.getElementById('model');
this.$ptt = document.getElementById('ptt');
this.$reset = document.getElementById('reset');
+ this.$scroll = document.getElementById('scroll');
this.$send = document.getElementById('send');
this.$textInput = document.getElementById('text-input');
this.$voice = document.getElementById('voice');
@@ -97,6 +104,8 @@ class Odidere {
this.#setLoadingState(false);
this.history = [];
+ this.isAutoScrollEnabled = true;
+ this._lastScrollTop = 0;
localStorage.removeItem(STORAGE_KEY);
this.$chat.innerHTML = '';
this.#clearAttachments();
@@ -151,7 +160,12 @@ class Odidere {
this.$reset.addEventListener('click', () => this.#handleResetClick());
this.document.addEventListener('click', (e) => this.#handleResetCancel(e));
- // File attachment
+ // Scroll button: two-press safety, swaps between scroll-to-bottom and
+ // scroll-to-top.
+ this.$scroll.addEventListener('click', () => this.#handleScrollClick());
+ this.document.addEventListener('click', (e) => this.#handleScrollCancel(e));
+
+ // File attachment.
this.$attach.addEventListener('click', () => this.$fileInput.click());
this.$fileInput.addEventListener('change', (e) =>
this.#handleAttachments(e.target.files),
@@ -167,6 +181,17 @@ class Odidere {
// Mute button
this.$mute.addEventListener('click', () => this.#toggleMute());
+ // Scroll button: update icon as user scrolls
+ this.$chat.addEventListener(
+ 'scroll',
+ () => {
+ this.#updateScrollIcon();
+ this.#handleChatScroll();
+ },
+ { passive: true },
+ );
+ this.#updateScrollIcon();
+
// Settings modal
this.$settings.addEventListener('click', () => this.openSettings());
this.$settingsClose.addEventListener('click', () => this.closeSettings());
@@ -359,6 +384,95 @@ class Odidere {
this.$reset.classList.remove('footer__toolbar-btn--pending');
}
+ /**
+ * #handleScrollClick toggles the pending scroll state on first press,
+ * executes the scroll on second press. At bottom → scroll to top,
+ * elsewhere → scroll to bottom.
+ */
+ #handleScrollClick() {
+ if (this.isScrollPending) {
+ this.isScrollPending = false;
+ this.$scroll.classList.remove('footer__toolbar-btn--pending');
+ if (this.#isAtBottom()) {
+ this.$chat.scrollTo({ top: 0, behavior: 'smooth' });
+ } else {
+ this.$chat.scrollTo({
+ top: this.$chat.scrollHeight,
+ behavior: 'smooth',
+ });
+ }
+ this.#updateScrollIcon();
+ } else {
+ this.isScrollPending = true;
+ this.$scroll.classList.add('footer__toolbar-btn--pending');
+ }
+ }
+
+ /**
+ * #handleScrollCancel clears the pending scroll state when the user
+ * clicks anywhere outside the scroll button.
+ * @param {MouseEvent} event
+ */
+ #handleScrollCancel(event) {
+ if (!this.isScrollPending) return;
+ if (this.$scroll.contains(event.target)) return;
+ this.isScrollPending = false;
+ this.$scroll.classList.remove('footer__toolbar-btn--pending');
+ }
+
+ /**
+ * #handleChatScroll detects whether the user scrolled up or down and
+ * toggles auto-scroll accordingly. Scrolling up disables it; scrolling
+ * back to the bottom re-enables it.
+ */
+ #handleChatScroll() {
+ const { scrollTop, scrollHeight, clientHeight } = this.$chat;
+ const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
+ const isAtBottom = distanceFromBottom < 10;
+
+ if (scrollTop < this._lastScrollTop && !isAtBottom) {
+ // User scrolled up and is not at the bottom → disable auto-scroll.
+ this.isAutoScrollEnabled = false;
+ } else if (isAtBottom) {
+ // User scrolled back to the bottom → re-enable auto-scroll.
+ this.isAutoScrollEnabled = true;
+ }
+
+ this._lastScrollTop = scrollTop;
+ }
+
+ /**
+ * #isAtBottom checks if the chat is scrolled within 10px of the bottom.
+ * @returns {boolean}
+ */
+ #isAtBottom() {
+ const { scrollTop, scrollHeight, clientHeight } = this.$chat;
+ return scrollHeight - clientHeight - scrollTop < 10;
+ }
+
+ /**
+ * #updateScrollIcon toggles a CSS class to show the appropriate arrow
+ * and updates the aria-label. No DOM nodes are created on scroll.
+ */
+ #updateScrollIcon() {
+ const atBottom = this.#isAtBottom();
+ this.$scroll.classList.toggle('scrolled-up', !atBottom);
+ this.$scroll.setAttribute(
+ 'aria-label',
+ atBottom ? 'Scroll to top' : 'Scroll to bottom',
+ );
+ }
+
+ /**
+ * #scrollToBottom scrolls the chat to the bottom if auto-scroll is
+ * enabled (i.e., the user is following along and hasn't scrolled up).
+ */
+ #scrollToBottom() {
+ if (this.isAutoScrollEnabled) {
+ this.$chat.scrollTop = this.$chat.scrollHeight;
+ }
+ }
+
/**
* #handleSendClick routes to either send or stop based on loading state.
*/
@@ -651,6 +765,8 @@ class Odidere {
*/
async #sendRequest({ audio = null, text = '' }) {
this.#setLoadingState(true);
+ // Re-enable auto-scroll when the user sends a new message.
+ this.isAutoScrollEnabled = true;
this.currentController = new AbortController();
try {
@@ -1296,7 +1412,7 @@ class Odidere {
);
this.$chat.appendChild($msg);
- this.$chat.scrollTop = this.$chat.scrollHeight;
+ this.#scrollToBottom();
}
/**
@@ -1372,7 +1488,7 @@ class Odidere {
);
this.$chat.appendChild($msg);
- this.$chat.scrollTop = this.$chat.scrollHeight;
+ this.#scrollToBottom();
}
/**
@@ -1385,7 +1501,7 @@ class Odidere {
$msg.querySelector('.message__content').textContent = message;
this.$chat.appendChild($msg);
- this.$chat.scrollTop = this.$chat.scrollHeight;
+ this.#scrollToBottom();
}
// ====================
diff --git a/internal/service/templates/static/footer/toolbar.gohtml b/internal/service/templates/static/footer/toolbar.gohtml
index f60c106..ab4f9da 100644
--- a/internal/service/templates/static/footer/toolbar.gohtml
+++ b/internal/service/templates/static/footer/toolbar.gohtml
@@ -17,6 +17,15 @@
>
+