Keep H5 chat scrolling inside the message pane

iPhone Chrome uses the iOS WebKit viewport model, so using scrollIntoView on the transcript sentinel can ask the browser to scroll ancestor containers, including the page viewport. That is risky with the fixed bottom composer and can expose blank page space when the latest button is clicked. This keeps all latest-message movement scoped to the chat scroll element and follows post-render height changes while streaming.

Constraint: H5 and desktop share MessageList, but the H5 browser runs through iOS WebKit viewport behavior.

Rejected: Keep bottomRef.scrollIntoView with container options | container support is not reliable enough and the default still targets all scrollable ancestors.

Confidence: high

Scope-risk: narrow

Directive: Do not reintroduce transcript scrollIntoView without real iPhone Chrome verification.

Tested: cd desktop && bun run test -- MessageList.test.tsx

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run build

Tested: git diff --check
This commit is contained in:
程序员阿江(Relakkes) 2026-05-15 20:38:46 +08:00
parent 571f16135f
commit 76d472376b
2 changed files with 110 additions and 13 deletions

View File

@ -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(<MessageList />)
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()
})

View File

@ -541,7 +541,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const activeThinkingId = sessionState?.activeThinkingId ?? null
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {}
const scrollContainerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
const shouldAutoScrollRef = useRef(true)
const isProgrammaticScrollingRef = useRef(false)
const lastSessionIdRef = useRef<string | null | undefined>(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'}`}
>
<div
ref={scrollContentRef}
className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}
role={shouldVirtualize ? 'list' : undefined}
>
@ -950,7 +971,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
</div>
)}
<div ref={bottomRef} />
<div />
</div>
</div>