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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 08:40:00 +00:00
1 parent 9fafea8fdf
commit 4eab1148be
2 files changed
+85 -1

No files matched your search

@@ -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)
})
})
+8 -1
View File
@@ -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 })