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)