mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +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 { 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 { MessageList, buildRenderModel } from './MessageList'
|
||||||
import { sessionsApi } from '../../api/sessions'
|
import { sessionsApi } from '../../api/sessions'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
|
import { useSettingsStore } from '../../stores/settingsStore'
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import type { UIMessage } from '../../types/chat'
|
import type { UIMessage } from '../../types/chat'
|
||||||
import type { PerSessionState } from '../../stores/chatStore'
|
import type { PerSessionState } from '../../stores/chatStore'
|
||||||
@ -35,6 +36,7 @@ function makeSessionState(overrides: Partial<PerSessionState> = {}): PerSessionS
|
|||||||
describe('MessageList nested tool calls', () => {
|
describe('MessageList nested tool calls', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
|
useSettingsStore.setState({ locale: 'en' })
|
||||||
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
|
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
|
||||||
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
|
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', () => {
|
it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => {
|
||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
sessions: {
|
sessions: {
|
||||||
|
|||||||
@ -118,6 +118,15 @@ type MessageListProps = {
|
|||||||
sessionId?: string | null
|
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 = {}) {
|
export function MessageList({ sessionId }: MessageListProps = {}) {
|
||||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||||
const resolvedSessionId = sessionId ?? activeTabId
|
const resolvedSessionId = sessionId ?? activeTabId
|
||||||
@ -136,7 +145,10 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
|
|||||||
const streamingText = sessionState?.streamingText ?? ''
|
const streamingText = sessionState?.streamingText ?? ''
|
||||||
const activeThinkingId = sessionState?.activeThinkingId ?? null
|
const activeThinkingId = sessionState?.activeThinkingId ?? null
|
||||||
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {}
|
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {}
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const shouldAutoScrollRef = useRef(true)
|
||||||
|
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const [rewindTarget, setRewindTarget] = useState<{
|
const [rewindTarget, setRewindTarget] = useState<{
|
||||||
userMessageIndex: number
|
userMessageIndex: number
|
||||||
@ -148,9 +160,22 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
|
|||||||
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
|
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
|
||||||
const [isExecutingRewind, setIsExecutingRewind] = useState(false)
|
const [isExecutingRewind, setIsExecutingRewind] = useState(false)
|
||||||
|
|
||||||
|
const updateAutoScrollState = useCallback(() => {
|
||||||
|
const container = scrollContainerRef.current
|
||||||
|
if (!container) return
|
||||||
|
shouldAutoScrollRef.current = isNearScrollBottom(container)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (lastSessionIdRef.current !== resolvedSessionId) {
|
||||||
|
shouldAutoScrollRef.current = true
|
||||||
|
lastSessionIdRef.current = resolvedSessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldAutoScrollRef.current) return
|
||||||
|
|
||||||
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
|
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
|
||||||
}, [messages.length, streamingText])
|
}, [messages.length, resolvedSessionId, streamingText])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resolvedSessionId || !rewindTarget) return
|
if (!resolvedSessionId || !rewindTarget) return
|
||||||
@ -268,7 +293,11 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
|
|||||||
let visibleUserMessageIndex = -1
|
let visibleUserMessageIndex = -1
|
||||||
|
|
||||||
return (
|
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]">
|
<div className="mx-auto max-w-[860px]">
|
||||||
{renderItems.map((item) => {
|
{renderItems.map((item) => {
|
||||||
if (item.kind === 'tool_group') {
|
if (item.kind === 'tool_group') {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user