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 ',
+ '}',
+ '```',
+ ].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),