mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: respect manual chat scroll position
This commit is contained in:
parent
55c11b481b
commit
e63db20136
@ -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: {
|
||||
|
||||
@ -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') {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user