diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 5755fce4..c417159e 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -932,6 +932,77 @@ describe('MessageList nested tool calls', () => { expect(scrollIntoView).toHaveBeenCalled() }) + it('keeps mobile H5 streaming output pinned after the transcript height grows', async () => { + const scrollIntoView = vi.fn() + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }) + + 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 waitForProgrammaticScrollReset() + fireEvent.scroll(scroller) + expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull() + + scrollIntoView.mockClear() + scrollHeight = 1400 + act(() => { + useChatStore.setState((state) => ({ + sessions: { + ...state.sessions, + [ACTIVE_TAB]: { + ...state.sessions[ACTIVE_TAB]!, + streamingText: 'streaming next token after height change', + }, + }, + })) + }) + + await waitFor(() => { + expect(screen.getByText('streaming next token after height change')).toBeTruthy() + }) + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' }) + expect(scrollTop).toBe(1000) + + await waitForProgrammaticScrollReset() + fireEvent.scroll(scroller) + + 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', { @@ -1120,6 +1191,81 @@ describe('MessageList nested tool calls', () => { expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' }) }) + it('jumps to the latest message when the user sends a new prompt from history', async () => { + const scrollIntoView = vi.fn() + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '历史消息', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: '历史回复', + timestamp: 2, + }, + ], + }), + }, + }) + + const { container } = render() + const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement + let scrollTop = 120 + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 }) + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 }) + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value) => { + scrollTop = value + }, + }) + + scrollIntoView.mockClear() + await waitForProgrammaticScrollReset() + fireEvent.scroll(scroller) + expect(screen.getByRole('button', { name: 'Latest' })).toBeTruthy() + + act(() => { + useChatStore.setState((state) => ({ + sessions: { + ...state.sessions, + [ACTIVE_TAB]: { + ...state.sessions[ACTIVE_TAB]!, + chatState: 'thinking', + messages: [ + ...state.sessions[ACTIVE_TAB]!.messages, + { + id: 'user-2', + type: 'user_text', + content: '新的问题', + timestamp: 3, + }, + ], + }, + }, + })) + }) + + await waitFor(() => { + expect(screen.getByText('新的问题')).toBeTruthy() + }) + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' }) + expect(scrollTop).toBe(600) + expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull() + }) + it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index ad66286e..2e886c43 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -511,7 +511,11 @@ function rememberSessionScroll(sessionId: string, element: HTMLElement) { } function clampScrollTop(element: HTMLElement, scrollTop: number) { - return Math.max(0, Math.min(scrollTop, element.scrollHeight - element.clientHeight)) + return Math.max(0, Math.min(scrollTop, getBottomScrollTop(element))) +} + +function getBottomScrollTop(element: HTMLElement) { + return Math.max(0, element.scrollHeight - element.clientHeight) } function getRenderItemKey(item: RenderItem) { @@ -541,6 +545,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const shouldAutoScrollRef = useRef(true) const isProgrammaticScrollingRef = useRef(false) const lastSessionIdRef = useRef(resolvedSessionId) + const lastTailMessageIdBySessionRef = useRef(new Map()) const updateVirtualWindowRef = useRef<((element: HTMLElement) => void) | null>(null) const t = useTranslation() const [turnChangeCards, setTurnChangeCards] = useState([]) @@ -554,17 +559,39 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const scrollToBottom = useCallback((behavior: ScrollBehavior) => { shouldAutoScrollRef.current = true isProgrammaticScrollingRef.current = true - bottomRef.current?.scrollIntoView?.({ behavior, block: 'end' }) const container = scrollContainerRef.current + const targetScrollTop = container ? getBottomScrollTop(container) : null + if (container) { + container.scrollTop = targetScrollTop ?? 0 + } + bottomRef.current?.scrollIntoView?.({ behavior, block: 'end' }) if (container && resolvedSessionId) { sessionScrollSnapshots.set(resolvedSessionId, { - scrollTop: Math.max(0, container.scrollHeight - container.clientHeight), + scrollTop: getBottomScrollTop(container), wasAtBottom: true, }) } setShowJumpToLatest(false) // Reset flag after the scroll event(s) from scrollIntoView have fired requestAnimationFrame(() => { + const latestContainer = scrollContainerRef.current + if ( + shouldAutoScrollRef.current && + latestContainer && + ( + targetScrollTop === null || + latestContainer.scrollTop === targetScrollTop || + isNearScrollBottom(latestContainer) + ) + ) { + latestContainer.scrollTop = getBottomScrollTop(latestContainer) + if (resolvedSessionId) { + sessionScrollSnapshots.set(resolvedSessionId, { + scrollTop: getBottomScrollTop(latestContainer), + wasAtBottom: true, + }) + } + } isProgrammaticScrollingRef.current = false }) }, [resolvedSessionId]) @@ -601,6 +628,22 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { } }, [resolvedSessionId, scrollToBottom]) + const tailMessage = messages[messages.length - 1] ?? null + const tailMessageId = tailMessage?.id ?? null + const tailMessageType = tailMessage?.type ?? null + + useEffect(() => { + if (!resolvedSessionId) return + + const previousTailMessageId = lastTailMessageIdBySessionRef.current.get(resolvedSessionId) + lastTailMessageIdBySessionRef.current.set(resolvedSessionId, tailMessageId) + if (previousTailMessageId === undefined || previousTailMessageId === tailMessageId) return + + if (tailMessageType === 'user_text' && chatState !== 'idle') { + scrollToBottom('smooth') + } + }, [chatState, resolvedSessionId, scrollToBottom, tailMessageId, tailMessageType]) + useEffect(() => { if (!shouldAutoScrollRef.current) { setShowJumpToLatest(true)