From 898a62e3b4caaa60dd8d9ec957112154b7f66272 Mon Sep 17 00:00:00 2001 From: Neko Date: Sat, 29 Aug 2026 12:45:36 +0800 Subject: [PATCH] fix(stage-ui): bottom-align mobile chat messages (#2398) --- .../chat/components/history.browser.test.ts | 44 +++++++++++++ .../scenarios/chat/components/history.vue | 10 ++- .../use-chat-history-scroll.browser.test.ts | 6 +- .../composables/use-chat-history-scroll.ts | 2 +- .../composables/use-virtualizer-scroll.ts | 63 ++++++++++++++++++- 5 files changed, 117 insertions(+), 8 deletions(-) diff --git a/packages/stage-ui/src/components/scenarios/chat/components/history.browser.test.ts b/packages/stage-ui/src/components/scenarios/chat/components/history.browser.test.ts index 51ea8171b..1602e89bb 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/history.browser.test.ts +++ b/packages/stage-ui/src/components/scenarios/chat/components/history.browser.test.ts @@ -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('.chat-history-list') + const messages = screen.container.querySelectorAll('.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 diff --git a/packages/stage-ui/src/components/scenarios/chat/components/history.vue b/packages/stage-ui/src/components/scenarios/chat/components/history.vue index 9d463f7fa..dbc33e1c9 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/history.vue +++ b/packages/stage-ui/src/components/scenarios/chat/components/history.vue @@ -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(() => { 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" >