diff --git a/src/renderer/src/pages/ChatPage.vue b/src/renderer/src/pages/ChatPage.vue index 5f358a60d..6eef85da4 100644 --- a/src/renderer/src/pages/ChatPage.vue +++ b/src/renderer/src/pages/ChatPage.vue @@ -384,7 +384,7 @@ function scrollToBottom(force = false) { const nextScrollHeight = el.scrollHeight if (shouldForce || nextScrollHeight > lastObservedScrollHeight) { - el.scrollTop = nextScrollHeight + el.scrollTop = Math.max(nextScrollHeight - el.clientHeight, 0) } syncScrollPosition() diff --git a/test/renderer/components/ChatPage.test.ts b/test/renderer/components/ChatPage.test.ts index fc4e70a1e..3671c3d91 100644 --- a/test/renderer/components/ChatPage.test.ts +++ b/test/renderer/components/ChatPage.test.ts @@ -931,6 +931,66 @@ describe('ChatPage', () => { expect(chatClient.steerActiveTurn).not.toHaveBeenCalled() }) + it('scrolls to bottom using max scrollTop during stream updates near bottom', async () => { + let nextFrameId = 1 + const rafCallbacks = new Map() + const flushRaf = async () => { + const callbacks = Array.from(rafCallbacks.values()) + rafCallbacks.clear() + callbacks.forEach((cb) => cb(0)) + await flushPromises() + } + const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + const frameId = nextFrameId + nextFrameId += 1 + rafCallbacks.set(frameId, cb) + return frameId + }) + const cancelRafSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((frameId) => { + rafCallbacks.delete(frameId) + }) + + try { + const { wrapper, messageStore } = await setup() + const chatPage = wrapper.get('[data-testid="chat-page"]').element as HTMLDivElement + + let scrollHeight = 1200 + let scrollTop = 0 + Object.defineProperty(chatPage, 'clientHeight', { + configurable: true, + get: () => 500 + }) + Object.defineProperty(chatPage, 'scrollHeight', { + configurable: true, + get: () => scrollHeight + }) + Object.defineProperty(chatPage, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value + } + }) + + await flushRaf() + + scrollTop = 700 + await wrapper.get('[data-testid="chat-page"]').trigger('scroll') + await flushPromises() + await flushRaf() + + scrollHeight = 1250 + messageStore.streamRevision += 1 + await flushPromises() + await flushRaf() + + expect(scrollTop).toBe(750) + } finally { + rafSpy.mockRestore() + cancelRafSpy.mockRestore() + } + }) + it('opens the inline search with Ctrl+F and closes it with Escape', async () => { const { wrapper } = await setup()