fix(stage-ui): bottom-align mobile chat messages (#2398)

This commit is contained in:
Neko
2026-08-29 12:45:36 +08:00
committed by GitHub
parent 1e7c468c03
commit 898a62e3b4
5 changed files with 117 additions and 8 deletions
@@ -19,6 +19,50 @@ function createEnglishI18n() {
}
describe('chat history', () => {
// ROOT CAUSE:
//
// Virtua keeps its internal content root at least as tall as the viewport, but
// absolutely positioned messages still start at the top. A short mobile history
// therefore leaves most of the chat area empty below a newly sent message.
//
// We fixed this by bottom-aligning short virtualized content while preserving
// the existing overflow direction for longer history.
it('bottom-aligns a newly sent mobile message and its streaming placeholder', async () => {
const screen = await render(ChatHistory, {
props: {
messages: [{ id: 'user-1', role: 'user', content: 'hello' }],
sending: true,
streamingMessage: {
id: 'assistant-1',
role: 'assistant',
content: '',
slices: [],
tool_results: [],
},
variant: 'mobile',
style: 'height: 240px; width: 320px; overflow-y: auto;',
},
global: {
plugins: [createEnglishI18n()],
},
})
await vi.waitFor(() => {
expect(screen.container.querySelector('.chat-message-item-visible')).not.toBeNull()
})
const history = screen.container.querySelector<HTMLElement>('.chat-history-list')
const messages = screen.container.querySelectorAll<HTMLElement>('.chat-message-item')
expect(history).not.toBeNull()
expect(messages).toHaveLength(2)
if (!history || messages.length !== 2)
throw new Error('Expected a sent message and its streaming placeholder.')
const historyBottom = history.getBoundingClientRect().bottom
expect(historyBottom - messages[1].getBoundingClientRect().bottom).toBeLessThanOrEqual(16)
expect(historyBottom - messages[0].getBoundingClientRect().bottom).toBeLessThanOrEqual(64)
})
// ROOT CAUSE:
//
// Rendering every message keeps every backdrop-filter surface alive, even when
@@ -15,7 +15,7 @@ import ChatUserItem from './user-item.vue'
import { useChatHistoryScroll } from '../composables/use-chat-history-scroll'
import { useChatHistoryTopFade } from '../composables/use-chat-history-top-fade'
import { useVirtualizerScroll } from '../composables/use-virtualizer-scroll'
import { useVirtualizerBottomAlignment, useVirtualizerScroll } from '../composables/use-virtualizer-scroll'
import { getChatHistoryItemKey } from '../utils'
const props = withDefaults(defineProps<{
@@ -75,8 +75,15 @@ const renderMessages = computed<ChatHistoryItem[]>(() => {
return [...props.messages, streaming.value]
})
const renderMessageCount = computed(() => renderMessages.value.length)
const topFadeRatio = computed(() => props.variant === 'mobile' ? 0.2 : 0)
const { itemProps } = useVirtualizerBottomAlignment({
container: chatHistoryRef,
itemCount: renderMessageCount,
virtualizer: virtualizerRef,
})
useChatHistoryScroll({
container: chatHistoryRef,
messages: renderMessages,
@@ -140,6 +147,7 @@ function emitToolCallRerun(
ref="virtualizer"
:data="renderMessages"
:buffer-size="CHAT_HISTORY_OVERSCAN"
:item-props="itemProps"
>
<template #default="{ item: message, index }">
<ChatHistoryMessageFrame
@@ -91,7 +91,7 @@ describe('useChatHistoryScroll', () => {
expect(scrollToIndex).toHaveBeenCalledWith(1, 'end')
})
it('aligns a new tail message to the viewport start', async () => {
it('aligns a new tail message to the viewport end', async () => {
const currentContainer = createScrollContainer(2)
currentContainer.scrollTop = currentContainer.scrollHeight
const container = shallowRef<HTMLElement | null>(currentContainer)
@@ -106,7 +106,7 @@ describe('useChatHistoryScroll', () => {
await flushReactivity()
expect(scrollToIndex).toHaveBeenCalledTimes(1)
expect(scrollToIndex).toHaveBeenCalledWith(2, 'start')
expect(scrollToIndex).toHaveBeenCalledWith(2, 'end')
})
it('blocks a new-message scroll while the reader points at an older message', async () => {
@@ -143,7 +143,7 @@ describe('useChatHistoryScroll', () => {
messages.value = [...messages.value, { id: 'assistant-2' }]
await flushReactivity()
expect(scrollToIndex).toHaveBeenCalledWith(2, 'start')
expect(scrollToIndex).toHaveBeenCalledWith(2, 'end')
})
it('stops following after a user scroll moves the viewport from the tail', async () => {
@@ -142,7 +142,7 @@ export function useChatHistoryScroll<TMessage>({
}
if (previousKey != null)
scrollToIndex(lastIndex, 'start')
scrollToIndex(lastIndex, 'end')
},
{ flush: 'post', immediate: true },
)
@@ -1,14 +1,20 @@
import type { VirtualizerHandle } from 'virtua/vue'
import type { ShallowRef } from 'vue'
import type { Ref, ShallowRef } from 'vue'
import { useRafFn } from '@vueuse/core'
import { shallowRef } from 'vue'
import { useMutationObserver, useRafFn, useResizeObserver } from '@vueuse/core'
import { shallowRef, watch } from 'vue'
interface VirtualScrollRequest {
align: 'start' | 'end'
index: number
}
interface VirtualizerBottomAlignmentOptions {
container: Readonly<ShallowRef<HTMLElement | null>>
itemCount: Readonly<Ref<number>>
virtualizer: Readonly<ShallowRef<VirtualizerHandle | null>>
}
/**
* Queues the latest index scroll until Virtua has measured its viewport.
*
@@ -54,3 +60,54 @@ export function useVirtualizerScroll(
},
}
}
/**
* Bottom-aligns a virtualized list while its measured content is shorter than its viewport.
*
* The returned item props translate every mounted item by the same amount, so Virtua keeps
* ownership of item measurement, absolute positioning, and overflow behavior.
*/
export function useVirtualizerBottomAlignment({
container,
itemCount,
virtualizer,
}: VirtualizerBottomAlignmentOptions) {
const bottomOffset = shallowRef(0)
const renderedItems = shallowRef<HTMLElement[]>([])
const { pause, resume } = useRafFn(() => {
const currentVirtualizer = virtualizer.value
const lastIndex = itemCount.value - 1
if (!currentVirtualizer || lastIndex < 0 || currentVirtualizer.viewportSize <= 0) {
bottomOffset.value = 0
pause()
return
}
const contentSize = currentVirtualizer.getItemOffset(lastIndex) + currentVirtualizer.getItemSize(lastIndex)
// NOTICE:
// Virtua keeps its content root at least as tall as the viewport, even for a short list.
// Its absolute item offsets therefore remain top-aligned after scrollToIndex(..., { align: 'end' }).
// Source: node_modules/virtua/src/core/store.ts getScrollSize and src/vue/Virtualizer.ts.
// Remove this translation when Virtua provides native short-list bottom alignment.
bottomOffset.value = Math.max(0, currentVirtualizer.viewportSize - contentSize)
pause()
}, { immediate: false })
useMutationObserver(container, () => {
renderedItems.value = Array.from(container.value?.querySelectorAll<HTMLElement>('.chat-message-item') ?? [])
resume()
}, { childList: true, subtree: true })
useResizeObserver(container, resume)
useResizeObserver(renderedItems, resume)
watch([container, itemCount], resume, { flush: 'post', immediate: true })
return {
itemProps: () => ({
style: {
transform: bottomOffset.value > 0 ? `translateY(${bottomOffset.value}px)` : undefined,
},
}),
}
}