From 4eab1148be763c67b54b25a7897efd5fc0f8168b Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 08:19:32 +0000 Subject: [PATCH] fix(ui): keep the chat following the bottom after a send A smooth scroll to the bottom from far up reports a distance above the pin threshold on its way down. The scroll handler read that as the reader scrolling up and unpinned the thread, so the new message and the answer that streamed in after it stayed out of view. Only a move up unpins the thread now. A reader who scrolls up while an answer streams is still left where they are. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- .../http/react-ui/e2e/chats-send-snap.spec.js | 77 +++++++++++++++++++ core/http/react-ui/src/pages/Chat.jsx | 9 ++- 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 core/http/react-ui/e2e/chats-send-snap.spec.js diff --git a/core/http/react-ui/e2e/chats-send-snap.spec.js b/core/http/react-ui/e2e/chats-send-snap.spec.js new file mode 100644 index 0000000000..28d441c1c8 --- /dev/null +++ b/core/http/react-ui/e2e/chats-send-snap.spec.js @@ -0,0 +1,77 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' + +// Sending a message snaps the thread to the bottom, also when the reader had +// scrolled up, so the new message is in view. Scrolling up while an answer +// streams still stays where the reader put it. + +const AT = '2026-10-25T10:00:00Z' +const LONG = 'A long line of history that fills the thread so that it needs to scroll. '.repeat(6) +const HISTORY = Array.from({ length: 40 }, (_, i) => ({ seq: i + 1, turn: Math.floor(i / 2) + 1, role: i % 2 ? 'assistant' : 'user', text: `Message ${i + 1}. ${LONG}`, at: AT })) + +async function open(page) { + await page.addInitScript(() => { + window.__sources = [] + window.EventSource = class { + constructor(url) { this.url = url; this.l = {}; window.__sources.push(this); setTimeout(() => this.onopen && this.onopen(), 0) } + addEventListener(kind, fn) { (this.l[kind] = this.l[kind] || []).push(fn) } + close() { this.closed = true } + } + window.__emit = (kind, payload) => { + const src = window.__sources.filter((x) => x.url.includes('/api/chats/c1/events') && !x.closed).pop() + for (const fn of (src && src.l[kind]) || []) fn({ data: JSON.stringify(payload) }) + } + }) + await mockChat(page) + const detail = { id: 'c1', title: 'Quotes', model: 'qwen3-8b', kind: 'chat', created: AT, updated: AT, running: false, last_turn: 'done', pending: [] } + let messages = HISTORY + await page.route(/\/api\/chats\/c1\/messages(\?.*)?$/, route => { + if (route.request().method() === 'POST') { + const text = route.request().postDataJSON().text + messages = [...messages, { seq: messages.length + 1, turn: 99, role: 'user', text, at: AT }] + return route.fulfill({ json: { started: true } }) + } + return route.fulfill({ json: { messages } }) + }) + await page.route('**/api/chats/c1', route => route.fulfill({ json: detail })) + await page.route(/\/api\/chats(\?.*)?$/, route => route.fulfill({ json: { conversations: [{ ...detail, pending: undefined }], persistent: true, files: true } })) + await page.route('**/api/chats/agents*', route => route.fulfill({ json: { agents: [] } })) + await page.setViewportSize({ width: 1280, height: 720 }) + await page.goto('/app/chats/c1') + await expect(page.getByTestId('chat-foot')).toBeVisible() +} + +const body = (page) => page.locator('.cx-body') +const gap = (page) => body(page).evaluate(el => el.scrollHeight - el.scrollTop - el.clientHeight) + +test.describe('Chat scroll on send', () => { + test('sending a message snaps to the bottom after the reader scrolled up', async ({ page }) => { + await open(page) + await expect(page.getByTestId('chat-thread')).toContainText('Message 40.') + await expect.poll(() => gap(page)).toBeLessThan(5) + await body(page).evaluate(el => { el.scrollTop = 0 }) + await expect.poll(() => gap(page)).toBeGreaterThan(500) + await page.getByTestId('chat-input').fill('the new question') + await page.getByTestId('chat-input').press('Enter') + await expect(page.getByTestId('chat-thread')).toContainText('the new question') + await expect.poll(() => gap(page), { timeout: 5000 }).toBeLessThan(5) + await expect(page.getByTestId('chat-thread').getByText('the new question')).toBeInViewport() + // The answer that streams in after it stays in view too. + await page.evaluate(() => window.__emit('turn_start', {})) + for (let i = 0; i < 6; i++) { + await page.evaluate(() => window.__emit('text', { text: 'The answer keeps coming in, line after line of it.\n\n'.repeat(6) })) + await page.waitForTimeout(150) + } + await expect.poll(() => gap(page), { timeout: 5000 }).toBeLessThan(5) + }) + + test('scrolling up while an answer streams is not undone', async ({ page }) => { + await open(page) + await expect.poll(() => gap(page)).toBeLessThan(5) + await body(page).evaluate(el => { el.scrollTop = 0 }) + await expect.poll(() => gap(page)).toBeGreaterThan(500) + await page.evaluate(() => window.__emit('message', { seq: 41, turn: 100, role: 'assistant', text: 'streaming answer '.repeat(80), at: '2026-10-25T10:00:00Z' })) + await page.waitForTimeout(500) + expect(await body(page).evaluate(el => el.scrollTop)).toBeLessThan(5) + }) +}) diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index 46e04576ff..fefb626044 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -300,9 +300,16 @@ function ChatPage() { useEffect(() => { const el = messagesRef.current if (!el) return undefined + let last = el.scrollTop const onScroll = () => { const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight - stickToBottomRef.current = distanceFromBottom < 80 + // Only a move up unpins the thread. Our own smooth scroll to the bottom + // moves down and reports a distance above the threshold on its way, which + // must not unpin it: the send and the streamed answer would stop following. + const up = el.scrollTop < last + last = el.scrollTop + if (distanceFromBottom < 80) stickToBottomRef.current = true + else if (up) stickToBottomRef.current = false setScrolledUp(distanceFromBottom > 160) } el.addEventListener('scroll', onScroll, { passive: true })