diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 25a92cf7..94555cab 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -1076,6 +1076,167 @@ describe('MessageList nested tool calls', () => { expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull() }) + it('does not pull a completed session back to the bottom when content resizes', 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: 'idle', + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '生成一个 todo app', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: [ + '已完成。', + '', + '```bash', + 'cd /private/tmp/todo-app', + 'npm run dev', + '```', + ].join('\n'), + timestamp: 2, + }, + ], + }), + }, + }) + + const { container } = render() + const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement + let scrollTop = 180 + let scrollHeight = 1400 + 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() + }) + + scrollIntoView.mockClear() + scrollHeight = 1600 + act(() => { + resizeCallback?.([], {} as ResizeObserver) + }) + + expect(scrollIntoView).not.toHaveBeenCalled() + expect(scrollTop).toBe(180) + }) + + it('does not pull a restored completed session back to the bottom from stale running state', 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: 'thinking', + activeThinkingId: null, + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '复盘这个已完成会话', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: [ + '这个会话已经完成。', + '', + '```tsx', + 'export function TodoListView() {', + ' return
Done
', + '}', + '```', + ].join('\n'), + timestamp: 2, + }, + ], + streamingText: '', + }), + }, + }) + + const { container } = render() + const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement + let scrollTop = 260 + let scrollHeight = 1800 + 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() + }) + + scrollIntoView.mockClear() + scrollHeight = 2100 + act(() => { + resizeCallback?.([], {} as ResizeObserver) + }) + + expect(scrollIntoView).not.toHaveBeenCalled() + expect(scrollTop).toBe(260) + }) + it('restores a session scroll position when switching back to a tab', async () => { const scrollIntoView = vi.fn() Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 5bb99da3..7376c2ea 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -540,6 +540,11 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const streamingText = sessionState?.streamingText ?? '' const activeThinkingId = sessionState?.activeThinkingId ?? null const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {} + const shouldFollowContentResize = + streamingText.trim().length > 0 || + chatState === 'streaming' || + chatState === 'tool_executing' || + (chatState === 'thinking' && Boolean(activeThinkingId)) const scrollContainerRef = useRef(null) const scrollContentRef = useRef(null) const shouldAutoScrollRef = useRef(true) @@ -669,13 +674,14 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { if (!content || typeof ResizeObserver === 'undefined') return const observer = new ResizeObserver(() => { + if (!shouldFollowContentResize) return if (!shouldAutoScrollRef.current) return scrollToBottom('auto') }) observer.observe(content) return () => observer.disconnect() - }, [scrollToBottom]) + }, [scrollToBottom, shouldFollowContentResize]) const { toolResultMap, childToolCallsByParent, renderItems } = useMemo( () => buildRenderModel(messages),