fix: stop restored chats snapping to latest

Restored transcripts can still present stale running state while their final markdown and tool output continue to reflow. Resize-driven auto-follow now stays enabled only for live resizable output, so readers can scroll up after completion while active streams still remain pinned.

Constraint: Old H5 sessions can reconnect with stale non-idle UI state even when the transcript has an end_turn.
Rejected: Disable resize following entirely | active H5 streaming would regress when rendered content grows after tokens arrive.
Confidence: high
Scope-risk: narrow
Directive: Do not broaden ResizeObserver auto-follow without a restored completed-session regression test.
Tested: cd desktop && bun run test -- MessageList.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: Browser smoke on session 85862799-ec6b-43d0-b3b8-6e9fd9130954 kept scrollTop at 320 after content resize.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-15 21:58:31 +08:00
parent 6ec8ae626b
commit 80291715b4
2 changed files with 168 additions and 1 deletions

View File

@ -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(<MessageList />)
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 <section>Done</section>',
'}',
'```',
].join('\n'),
timestamp: 2,
},
],
streamingText: '',
}),
},
})
const { container } = render(<MessageList />)
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', {

View File

@ -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<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(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),