diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index a0b77b1d..92f0ba1f 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -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 = {}): 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() + 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() + 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: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 753c92f2..3c6cb537 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -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(null) const bottomRef = useRef(null) + const shouldAutoScrollRef = useRef(true) + const lastSessionIdRef = useRef(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 ( -
+
{renderItems.map((item) => { if (item.kind === 'tool_group') {