diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index c417159e..a5d8f151 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -101,6 +101,7 @@ async function selectMessageText(element: Element, text: string) { describe('MessageList nested tool calls', () => { beforeEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() useSettingsStore.setState({ locale: 'en' }) useUIStore.setState({ pendingSettingsTab: null }) useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] }) @@ -929,7 +930,8 @@ describe('MessageList nested tool calls', () => { await waitFor(() => { expect(screen.getByText('streaming next token')).toBeTruthy() }) - expect(scrollIntoView).toHaveBeenCalled() + expect(scrollIntoView).not.toHaveBeenCalled() + expect(scrollTop).toBe(600) }) it('keeps mobile H5 streaming output pinned after the transcript height grows', async () => { @@ -994,7 +996,7 @@ describe('MessageList nested tool calls', () => { await waitFor(() => { expect(screen.getByText('streaming next token after height change')).toBeTruthy() }) - expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' }) + expect(scrollIntoView).not.toHaveBeenCalled() expect(scrollTop).toBe(1000) await waitForProgrammaticScrollReset() @@ -1003,6 +1005,77 @@ describe('MessageList nested tool calls', () => { expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull() }) + it('keeps H5 pinned when streaming content resizes after render', async () => { + const scrollIntoView = vi.fn() + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }) + + let resizeCallback: ResizeObserverCallback | null = null + class TestResizeObserver { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback + } + } + vi.stubGlobal('ResizeObserver', TestResizeObserver) + + 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 + let scrollHeight = 1000 + Object.defineProperty(scroller, 'scrollHeight', { + configurable: true, + get: () => scrollHeight, + }) + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 }) + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value) => { + scrollTop = value + }, + }) + + await waitFor(() => { + expect(resizeCallback).not.toBeNull() + }) + await waitForProgrammaticScrollReset() + fireEvent.scroll(scroller) + expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull() + + scrollIntoView.mockClear() + scrollHeight = 1600 + act(() => { + resizeCallback?.([], {} as ResizeObserver) + }) + + expect(scrollIntoView).not.toHaveBeenCalled() + expect(scrollTop).toBe(1200) + expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull() + }) + it('restores a session scroll position when switching back to a tab', async () => { const scrollIntoView = vi.fn() Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { @@ -1123,8 +1196,9 @@ describe('MessageList nested tool calls', () => { await waitFor(() => { expect(screen.getByText('Fresh latest response')).toBeTruthy() - expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'end' }) + expect(scrollIntoView).not.toHaveBeenCalled() }) + expect(scroller.scrollTop).toBe(800) }) it('shows a latest button when reading history and resumes following after clicking it', async () => { @@ -1169,7 +1243,8 @@ describe('MessageList nested tool calls', () => { fireEvent.scroll(scroller) fireEvent.click(screen.getByRole('button', { name: 'Latest' })) - expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' }) + expect(scrollIntoView).not.toHaveBeenCalled() + expect(scrollTop).toBe(600) expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull() scrollIntoView.mockClear() @@ -1188,7 +1263,8 @@ describe('MessageList nested tool calls', () => { await waitFor(() => { expect(screen.getByText('streaming after jump')).toBeTruthy() }) - expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' }) + expect(scrollIntoView).not.toHaveBeenCalled() + expect(scrollTop).toBe(600) }) it('jumps to the latest message when the user sends a new prompt from history', async () => { @@ -1261,7 +1337,7 @@ describe('MessageList nested tool calls', () => { await waitFor(() => { expect(screen.getByText('新的问题')).toBeTruthy() }) - expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' }) + expect(scrollIntoView).not.toHaveBeenCalled() expect(scrollTop).toBe(600) expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull() }) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 2e886c43..5919a0c5 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -541,7 +541,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const activeThinkingId = sessionState?.activeThinkingId ?? null const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {} const scrollContainerRef = useRef(null) - const bottomRef = useRef(null) + const scrollContentRef = useRef(null) const shouldAutoScrollRef = useRef(true) const isProgrammaticScrollingRef = useRef(false) const lastSessionIdRef = useRef(resolvedSessionId) @@ -562,9 +562,16 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const container = scrollContainerRef.current const targetScrollTop = container ? getBottomScrollTop(container) : null if (container) { - container.scrollTop = targetScrollTop ?? 0 + const nextScrollTop = targetScrollTop ?? 0 + if (typeof container.scrollTo === 'function') { + try { + container.scrollTo({ top: nextScrollTop, behavior }) + } catch { + container.scrollTo(0, nextScrollTop) + } + } + container.scrollTop = nextScrollTop } - bottomRef.current?.scrollIntoView?.({ behavior, block: 'end' }) if (container && resolvedSessionId) { sessionScrollSnapshots.set(resolvedSessionId, { scrollTop: getBottomScrollTop(container), @@ -640,7 +647,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { if (previousTailMessageId === undefined || previousTailMessageId === tailMessageId) return if (tailMessageType === 'user_text' && chatState !== 'idle') { - scrollToBottom('smooth') + scrollToBottom('auto') } }, [chatState, resolvedSessionId, scrollToBottom, tailMessageId, tailMessageType]) @@ -650,11 +657,24 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { return } - scrollToBottom('smooth') + scrollToBottom('auto') }, [messages.length, resolvedSessionId, scrollToBottom, streamingText]) const handleJumpToLatest = useCallback(() => { - scrollToBottom('smooth') + scrollToBottom('auto') + }, [scrollToBottom]) + + useEffect(() => { + const content = scrollContentRef.current + if (!content || typeof ResizeObserver === 'undefined') return + + const observer = new ResizeObserver(() => { + if (!shouldAutoScrollRef.current) return + scrollToBottom('auto') + }) + observer.observe(content) + + return () => observer.disconnect() }, [scrollToBottom]) const { toolResultMap, childToolCallsByParent, renderItems } = useMemo( @@ -861,6 +881,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`} >
@@ -950,7 +971,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
)} -
+