fix(stage-ui): bottom-align mobile chat messages (#2398)
This commit is contained in:
@@ -19,6 +19,50 @@ function createEnglishI18n() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('chat history', () => {
|
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:
|
// ROOT CAUSE:
|
||||||
//
|
//
|
||||||
// Rendering every message keeps every backdrop-filter surface alive, even when
|
// 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 { useChatHistoryScroll } from '../composables/use-chat-history-scroll'
|
||||||
import { useChatHistoryTopFade } from '../composables/use-chat-history-top-fade'
|
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'
|
import { getChatHistoryItemKey } from '../utils'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -75,8 +75,15 @@ const renderMessages = computed<ChatHistoryItem[]>(() => {
|
|||||||
|
|
||||||
return [...props.messages, streaming.value]
|
return [...props.messages, streaming.value]
|
||||||
})
|
})
|
||||||
|
const renderMessageCount = computed(() => renderMessages.value.length)
|
||||||
const topFadeRatio = computed(() => props.variant === 'mobile' ? 0.2 : 0)
|
const topFadeRatio = computed(() => props.variant === 'mobile' ? 0.2 : 0)
|
||||||
|
|
||||||
|
const { itemProps } = useVirtualizerBottomAlignment({
|
||||||
|
container: chatHistoryRef,
|
||||||
|
itemCount: renderMessageCount,
|
||||||
|
virtualizer: virtualizerRef,
|
||||||
|
})
|
||||||
|
|
||||||
useChatHistoryScroll({
|
useChatHistoryScroll({
|
||||||
container: chatHistoryRef,
|
container: chatHistoryRef,
|
||||||
messages: renderMessages,
|
messages: renderMessages,
|
||||||
@@ -140,6 +147,7 @@ function emitToolCallRerun(
|
|||||||
ref="virtualizer"
|
ref="virtualizer"
|
||||||
:data="renderMessages"
|
:data="renderMessages"
|
||||||
:buffer-size="CHAT_HISTORY_OVERSCAN"
|
:buffer-size="CHAT_HISTORY_OVERSCAN"
|
||||||
|
:item-props="itemProps"
|
||||||
>
|
>
|
||||||
<template #default="{ item: message, index }">
|
<template #default="{ item: message, index }">
|
||||||
<ChatHistoryMessageFrame
|
<ChatHistoryMessageFrame
|
||||||
|
|||||||
+3
-3
@@ -91,7 +91,7 @@ describe('useChatHistoryScroll', () => {
|
|||||||
expect(scrollToIndex).toHaveBeenCalledWith(1, 'end')
|
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)
|
const currentContainer = createScrollContainer(2)
|
||||||
currentContainer.scrollTop = currentContainer.scrollHeight
|
currentContainer.scrollTop = currentContainer.scrollHeight
|
||||||
const container = shallowRef<HTMLElement | null>(currentContainer)
|
const container = shallowRef<HTMLElement | null>(currentContainer)
|
||||||
@@ -106,7 +106,7 @@ describe('useChatHistoryScroll', () => {
|
|||||||
await flushReactivity()
|
await flushReactivity()
|
||||||
|
|
||||||
expect(scrollToIndex).toHaveBeenCalledTimes(1)
|
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 () => {
|
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' }]
|
messages.value = [...messages.value, { id: 'assistant-2' }]
|
||||||
await flushReactivity()
|
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 () => {
|
it('stops following after a user scroll moves the viewport from the tail', async () => {
|
||||||
|
|||||||
+1
-1
@@ -142,7 +142,7 @@ export function useChatHistoryScroll<TMessage>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (previousKey != null)
|
if (previousKey != null)
|
||||||
scrollToIndex(lastIndex, 'start')
|
scrollToIndex(lastIndex, 'end')
|
||||||
},
|
},
|
||||||
{ flush: 'post', immediate: true },
|
{ flush: 'post', immediate: true },
|
||||||
)
|
)
|
||||||
|
|||||||
+60
-3
@@ -1,14 +1,20 @@
|
|||||||
import type { VirtualizerHandle } from 'virtua/vue'
|
import type { VirtualizerHandle } from 'virtua/vue'
|
||||||
import type { ShallowRef } from 'vue'
|
import type { Ref, ShallowRef } from 'vue'
|
||||||
|
|
||||||
import { useRafFn } from '@vueuse/core'
|
import { useMutationObserver, useRafFn, useResizeObserver } from '@vueuse/core'
|
||||||
import { shallowRef } from 'vue'
|
import { shallowRef, watch } from 'vue'
|
||||||
|
|
||||||
interface VirtualScrollRequest {
|
interface VirtualScrollRequest {
|
||||||
align: 'start' | 'end'
|
align: 'start' | 'end'
|
||||||
index: number
|
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.
|
* 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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user