fix: respect manual chat scroll position

This commit is contained in:
Relakkes Yang 2026-04-25 13:59:22 +08:00
parent 55c11b481b
commit e63db20136
2 changed files with 150 additions and 3 deletions

View File

@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MessageList, buildRenderModel } from './MessageList'
import { sessionsApi } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import type { UIMessage } from '../../types/chat'
import type { PerSessionState } from '../../stores/chatStore'
@ -35,6 +36,7 @@ function makeSessionState(overrides: Partial<PerSessionState> = {}): PerSessionS
describe('MessageList nested tool calls', () => {
beforeEach(() => {
vi.restoreAllMocks()
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
})
@ -338,6 +340,122 @@ describe('MessageList nested tool calls', () => {
)
})
it('does not force-scroll to the bottom while the user is reading history', async () => {
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
})
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'streaming',
messages: [
{
id: 'user-1',
type: 'user_text',
content: '历史消息',
timestamp: 1,
},
],
streamingText: 'streaming',
}),
},
})
const { container } = render(<MessageList />)
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
let scrollTop = 120
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 })
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = value
},
})
scrollIntoView.mockClear()
fireEvent.scroll(scroller)
act(() => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[ACTIVE_TAB]: {
...state.sessions[ACTIVE_TAB]!,
streamingText: 'streaming new token',
},
},
}))
})
await waitFor(() => {
expect(screen.getByText('streaming new token')).toBeTruthy()
})
expect(scrollIntoView).not.toHaveBeenCalled()
})
it('keeps auto-scrolling when new output arrives while already near the bottom', async () => {
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
})
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'streaming',
messages: [
{
id: 'user-1',
type: 'user_text',
content: '最新消息',
timestamp: 1,
},
],
streamingText: 'streaming',
}),
},
})
const { container } = render(<MessageList />)
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
let scrollTop = 552
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 })
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = value
},
})
scrollIntoView.mockClear()
fireEvent.scroll(scroller)
act(() => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[ACTIVE_TAB]: {
...state.sessions[ACTIVE_TAB]!,
streamingText: 'streaming next token',
},
},
}))
})
await waitFor(() => {
expect(screen.getByText('streaming next token')).toBeTruthy()
})
expect(scrollIntoView).toHaveBeenCalled()
})
it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => {
useChatStore.setState({
sessions: {

View File

@ -118,6 +118,15 @@ type MessageListProps = {
sessionId?: string | null
}
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
function isNearScrollBottom(element: HTMLElement) {
return (
element.scrollHeight - element.scrollTop - element.clientHeight <=
AUTO_SCROLL_BOTTOM_THRESHOLD_PX
)
}
export function MessageList({ sessionId }: MessageListProps = {}) {
const activeTabId = useTabStore((s) => s.activeTabId)
const resolvedSessionId = sessionId ?? activeTabId
@ -136,7 +145,10 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
const streamingText = sessionState?.streamingText ?? ''
const activeThinkingId = sessionState?.activeThinkingId ?? null
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {}
const scrollContainerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const shouldAutoScrollRef = useRef(true)
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
const t = useTranslation()
const [rewindTarget, setRewindTarget] = useState<{
userMessageIndex: number
@ -148,9 +160,22 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
const [isExecutingRewind, setIsExecutingRewind] = useState(false)
const updateAutoScrollState = useCallback(() => {
const container = scrollContainerRef.current
if (!container) return
shouldAutoScrollRef.current = isNearScrollBottom(container)
}, [])
useEffect(() => {
if (lastSessionIdRef.current !== resolvedSessionId) {
shouldAutoScrollRef.current = true
lastSessionIdRef.current = resolvedSessionId
}
if (!shouldAutoScrollRef.current) return
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
}, [messages.length, streamingText])
}, [messages.length, resolvedSessionId, streamingText])
useEffect(() => {
if (!resolvedSessionId || !rewindTarget) return
@ -268,7 +293,11 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
let visibleUserMessageIndex = -1
return (
<div className="flex-1 overflow-y-auto px-4 py-4">
<div
ref={scrollContainerRef}
onScroll={updateAutoScrollState}
className="flex-1 overflow-y-auto px-4 py-4"
>
<div className="mx-auto max-w-[860px]">
{renderItems.map((item) => {
if (item.kind === 'tool_group') {