mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
571f16135f
commit
76d472376b
@ -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()
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user