From 76d472376b3dd6ee363339c58a44fa6cd6601706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 15 May 2026 20:38:46 +0800 Subject: [PATCH] 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 --- .../src/components/chat/MessageList.test.tsx | 88 +++++++++++++++++-- desktop/src/components/chat/MessageList.tsx | 35 ++++++-- 2 files changed, 110 insertions(+), 13 deletions(-) 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 = {
)} -
+