diff --git a/web/components/copilot.js b/web/components/copilot.js index 4eacf6a..cb8a964 100644 --- a/web/components/copilot.js +++ b/web/components/copilot.js @@ -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')} ` : nothing} + + ${this._showJump ? html` + + ` : nothing}
diff --git a/web/components/shared/chat-page.js b/web/components/shared/chat-page.js index 5fa21c8..e276d54 100644 --- a/web/components/shared/chat-page.js +++ b/web/components/shared/chat-page.js @@ -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')}
` : nothing} + + ${this._showJump ? html` + + ` : nothing}
diff --git a/web/css/copilot-messages.css b/web/css/copilot-messages.css index 0bf3bb1..b1ae27f 100644 --- a/web/css/copilot-messages.css +++ b/web/css/copilot-messages.css @@ -9,6 +9,39 @@ gap: 0.7rem; } +/* ── "Jump to latest" floating affordance ──────────────────────────────────── */ +/* Shown only while the reader is scrolled away from the bottom (auto-scroll has + yielded). Sticky to the scrollport bottom so it stays in view as they read; + removed from the DOM entirely while stuck to the bottom. */ +.copilot-jump-btn { + position: sticky; + bottom: 0.6rem; + align-self: flex-end; + width: 38px; + height: 38px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid var(--card-border); + border-radius: 50%; + background: rgba(var(--accent-rgb), 0.92); + color: #fff; + box-shadow: var(--card-shadow); + cursor: pointer; + z-index: 5; + font-size: 1.1rem; + line-height: 1; + transition: transform 0.12s ease, background-color 0.12s ease; +} +.copilot-jump-btn:hover { background: var(--accent); transform: translateY(-1px); } +.copilot-jump-btn:active { transform: translateY(0); } +.copilot-jump-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +@media (prefers-reduced-motion: reduce) { + .copilot-jump-btn { transition: none; } + .copilot-jump-btn:hover { transform: none; } +} + /* ── Message bubbles ───────────────────────────────────────────────────────── */ .copilot-msg { diff --git a/web/i18n/en.js b/web/i18n/en.js index 64c10b1..25d7f28 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -68,6 +68,7 @@ export default { 'chat.reasoning': 'Reasoning…', 'chat.attach': 'Attach files', 'chat.new_session': 'New conversation', + 'chat.scroll_to_latest': 'Scroll to latest', 'chat.security_group': 'Security group', 'chat.collapse': 'Hide chat', 'chat.close_tab': 'Close tab', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index 92b0d98..121ef78 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -68,6 +68,7 @@ export default { 'chat.reasoning': 'Raisonnement…', 'chat.attach': 'Joindre des fichiers', 'chat.new_session': 'Nouvelle conversation', + 'chat.scroll_to_latest': 'Aller aux derniers messages', 'chat.security_group': 'Groupe de sécurité', 'chat.collapse': 'Masquer la discussion', 'chat.close_tab': 'Fermer l\'onglet', diff --git a/web/i18n/it.js b/web/i18n/it.js index 85f318d..8997d66 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -68,6 +68,7 @@ export default { 'chat.reasoning': 'Ragionamento…', 'chat.attach': 'Allega file', 'chat.new_session': 'Nuova conversazione', + 'chat.scroll_to_latest': 'Vai agli ultimi messaggi', 'chat.security_group': 'Gruppo di sicurezza', 'chat.collapse': 'Nascondi la chat', 'chat.close_tab': 'Chiudi scheda', diff --git a/web/lib/chat-session.js b/web/lib/chat-session.js index 6dd5a1a..ceb4052 100644 --- a/web/lib/chat-session.js +++ b/web/lib/chat-session.js @@ -13,15 +13,24 @@ const SYSTEM_SLASH_COMMANDS = new Set([ '/compact', '/resettools', '/sethome', ]); +// Pixels from the bottom within which auto-scroll still "sticks". Scrolling +// further up during streaming makes auto-scroll yield so the reader is left in +// peace; scrolling back within this band re-arms it. +const SCROLL_STICKY_PX = 80; + /** * Base class for chat UI components (desktop copilot, mobile chat page). * * Contains all WebSocket logic, message state, and approval handling. * Subclasses implement the render() method and override the DOM hooks: - * - _scrollToBottom() - * - _getInputContent() → returns current input value - * - _clearInput() → empties the input + * - _messagesContainer() → returns the scrollable message-list element + * - _getInputContent() → returns current input value + * - _clearInput() → empties the input * - _onMessagePushed(item) → called after each push (scroll, focus, etc.) + * + * Auto-scroll is stick-to-bottom: it follows the stream only while the reader is + * within SCROLL_STICKY_PX of the bottom. Scrolling up to read pauses it, and a + * "jump to latest" affordance (driven by the `_showJump` state) is shown then. */ export class ChatSession extends LightElement { static properties = { @@ -42,6 +51,10 @@ export class ChatSession extends LightElement { // Voice recording state (shared by every chat surface). _hasTranscribe: { state: true }, _recording: { state: true }, + // Whether to show the "jump to latest" affordance: true only while the reader + // is scrolled away from the bottom (i.e. auto-scroll has yielded). Driven by + // the stickiness sensor in `_scrollToBottom`. + _showJump: { state: true }, // Pending attachments for the message being composed (shown as chips above // the textarea; uploaded to disk on selection, sent with the next message). _attachments: { state: true }, @@ -85,6 +98,14 @@ export class ChatSession extends LightElement { this._shortcutRecording = false; this._mediaRecorder = null; this._audioChunks = []; + // Stick-to-bottom auto-scroll: `_stickToBottom` is `true` while the reader is + // within SCROLL_STICKY_PX of the bottom. A manual scroll-up flips it false so + // live streaming stops fighting the reader; scrolling back within the band + // re-arms it. `_scrollEl` tracks the bound container so the scroll sensor is + // attached exactly once (and re-attached if the element is recreated). + this._stickToBottom = true; + this._scrollEl = null; + this._showJump = false; // Each entry: { name, path, mimetype, filesize, uploading? }. While an upload // is in flight the entry has `uploading: true` and no `path` yet. this._attachments = []; @@ -168,7 +189,7 @@ export class ChatSession extends LightElement { if (m.kind === 'tool' && m.status === 'pending') expanded.add(m.tool_call_id); } this._expanded = expanded; - this._scrollToBottom(); + this._forceScrollToBottom(); // Set flag so ws.onopen sends a resume if there are pending tools // (approval/clarification waiting) or interrupted tools (status=error+Interrupted). this._hasPendingTools = items.some( @@ -400,7 +421,7 @@ export class ChatSession extends LightElement { case 'approval_required': this._updateTool(msg.tool_call_id, { status: 'pending', request_id: msg.request_id }); this._expanded = new Set([...this._expanded, msg.tool_call_id]); - this.updateComplete.then(() => this._scrollToBottom()); + this._forceScrollToBottom(); break; case 'approval_resolved': { @@ -441,7 +462,7 @@ export class ChatSession extends LightElement { suggested_answers: msg.suggested_answers ?? [], }); this._expanded = new Set([...this._expanded, msg.tool_call_id]); - this.updateComplete.then(() => this._scrollToBottom()); + this._forceScrollToBottom(); break; case 'agent_start': @@ -657,6 +678,10 @@ export class ChatSession extends LightElement { ({ name, path, mimetype, filesize })); this._attachments = []; + // Sending implies the reader wants to follow the conversation: always land at + // the latest, even if they had scrolled up to read before sending. + this._forceScrollToBottom(); + // System slash commands reply with a `Done` and never echo back as a // `user_message`, so render them optimistically. Regular messages and custom // slash commands use telnet-style echo: no local push — the bubble appears only @@ -910,8 +935,56 @@ export class ChatSession extends LightElement { el.style.height = el.scrollHeight + 'px'; } - /** Scrolls the message list to the bottom. */ - _scrollToBottom() {} + /** Returns the scrollable message-list element. Subclasses must override. */ + _messagesContainer() { return null; } + + /** + * Scrolls the message list to the bottom — unless the reader has scrolled away + * from the bottom, in which case streaming is left to flow without yanking them + * back. Pass `force=true` for cases that must always land at the latest content + * (history load, the chat becoming visible, interactive approval/clarification + * prompts). + * + * Stickiness is tracked as a flag updated by a passive `scroll` listener on the + * container (attached lazily), NOT by re-measuring the distance on every call: + * a single fast flush can add more than the threshold of new content, which + * would otherwise make a stuck reader look "far from the bottom" and kill the + * auto-scroll. The listener recomputes the flag from the live scroll position, + * so scrolling back within the band re-arms it. + */ + _scrollToBottom(force = false) { + this.updateComplete.then(() => { + const el = this._messagesContainer(); + if (!el) return; + // Lazily bind the stickiness sensor to the (possibly recreated) container. + if (el !== this._scrollEl) { + this._scrollEl = el; + el.addEventListener('scroll', () => { + if (this._scrollEl !== el) return; + this._stickToBottom = + (el.scrollHeight - el.scrollTop - el.clientHeight) <= SCROLL_STICKY_PX; + this._updateJumpButton(); + }, { passive: true }); + } + if (force || this._stickToBottom) { + el.scrollTop = el.scrollHeight; + this._stickToBottom = true; + this._updateJumpButton(); + } + }); + } + + /** Always scrolls to the bottom regardless of the reader's position. */ + _forceScrollToBottom() { this._scrollToBottom(true); } + + /** "Jump to latest" handler: force-scroll and re-stick. */ + _jumpToBottom() { this._forceScrollToBottom(); } + + /** Toggles the `_showJump` state from the current stickiness (only on change). */ + _updateJumpButton() { + const show = !this._stickToBottom && this._messages.length > 0; + if (show !== this._showJump) this._showJump = show; + } // ── Voice recording (shared by every chat surface) ────────────────────────────