chat: stick-to-bottom auto-scroll with jump-to-latest button
Nightly Build / build (push) Successful in 6m51s

Auto-scroll now yields when the reader scrolls away from the bottom, so a
fast-streaming reply no longer fights someone reading the start. Stickiness is
a flag driven by a passive scroll listener (not a per-flush distance check,
which breaks when one flush adds more than the threshold of content); scrolling
back within the band re-arms it.

Centralised in chat-session.js (_scrollToBottom(force) + _forceScrollToBottom
+ _messagesContainer hook), removing the duplicated overrides in copilot.js /
chat-page.js. Force-scroll is used where the latest must be shown: history
load, chat reopen, approval/clarification prompts, and message send.

A sticky 'jump to latest' affordance appears only while scrolled up.
This commit is contained in:
2026-07-24 21:06:04 +01:00
parent d1d0a2af26
commit db6e395c11
7 changed files with 140 additions and 19 deletions
+11 -5
View File
@@ -162,11 +162,8 @@ export class AppCopilot extends I18nMixin(ChatSession) {
return this.querySelector('.copilot-textarea');
}
_scrollToBottom() {
this.updateComplete.then(() => {
const el = this.querySelector('.copilot-messages');
if (el) el.scrollTop = el.scrollHeight;
});
_messagesContainer() {
return this.querySelector('.copilot-messages');
}
_onMessagePushed(item) {
@@ -389,6 +386,15 @@ export class AppCopilot extends I18nMixin(ChatSession) {
${t('chat.thinking')}
</div>
` : nothing}
${this._showJump ? html`
<button class="copilot-jump-btn" type="button"
title=${t('chat.scroll_to_latest')}
aria-label=${t('chat.scroll_to_latest')}
@click=${() => this._jumpToBottom()}>
<i class="bi bi-arrow-down-circle-fill"></i>
</button>
` : nothing}
</div>
<div class="copilot-input-area">
+12 -6
View File
@@ -43,7 +43,7 @@ export class ChatPage extends ChatSession {
updated(changed) {
if (changed.has('visible') && this.visible) {
this._scrollToBottom();
this._forceScrollToBottom();
}
// The owner (mobile-app) re-points this chat by changing `source`. Switch the
// live connection — base `_switchSource` tears down the WS, reloads that
@@ -72,11 +72,8 @@ export class ChatPage extends ChatSession {
return this.querySelector('.chat-page-textarea');
}
_scrollToBottom() {
this.updateComplete.then(() => {
const el = this.querySelector('.chat-page-messages');
if (el) el.scrollTop = el.scrollHeight;
});
_messagesContainer() {
return this.querySelector('.chat-page-messages');
}
_onMessagePushed(item) {
@@ -184,6 +181,15 @@ export class ChatPage extends ChatSession {
${t('chat.thinking')}
</div>
` : nothing}
${this._showJump ? html`
<button class="copilot-jump-btn" type="button"
title=${t('chat.scroll_to_latest')}
aria-label=${t('chat.scroll_to_latest')}
@click=${() => this._jumpToBottom()}>
<i class="bi bi-arrow-down-circle-fill"></i>
</button>
` : nothing}
</div>
<div class="chat-page-input-area">