From b6d0809ecb6421d492c4ce38aa7a76146c83269d Mon Sep 17 00:00:00 2001 From: Neko Date: Fri, 21 Aug 2026 05:32:21 +0800 Subject: [PATCH] perf(stage-ui): virtualize chat history (#2334) --- .../Layouts/MobileInteractiveArea.vue | 15 - packages/stage-ui/package.json | 1 + .../chat/components/action-menu/index.vue | 8 +- .../chat/components/assistant-item.vue | 8 +- .../scenarios/chat/components/error-item.vue | 9 +- .../chat/components/history-message-frame.vue | 33 ++ .../chat/components/history.browser.test.ts | 249 ++++----- .../scenarios/chat/components/history.vue | 140 +++-- .../scenarios/chat/components/user-item.vue | 8 +- .../use-chat-history-scroll.browser.test.ts | 184 +++++++ .../use-chat-history-scroll.test.ts | 434 --------------- .../composables/use-chat-history-scroll.ts | 496 ++++-------------- .../composables/use-chat-history-top-fade.ts | 80 +++ .../composables/use-virtualizer-scroll.ts | 56 ++ .../components/scenarios/chat/constants.ts | 3 - pnpm-lock.yaml | 37 ++ pnpm-workspace.yaml | 1 + 17 files changed, 753 insertions(+), 1009 deletions(-) create mode 100644 packages/stage-ui/src/components/scenarios/chat/components/history-message-frame.vue create mode 100644 packages/stage-ui/src/components/scenarios/chat/composables/use-chat-history-scroll.browser.test.ts delete mode 100644 packages/stage-ui/src/components/scenarios/chat/composables/use-chat-history-scroll.test.ts create mode 100644 packages/stage-ui/src/components/scenarios/chat/composables/use-chat-history-top-fade.ts create mode 100644 packages/stage-ui/src/components/scenarios/chat/composables/use-virtualizer-scroll.ts delete mode 100644 packages/stage-ui/src/components/scenarios/chat/constants.ts diff --git a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue index d13783cef..cf6b0281f 100644 --- a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue +++ b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue @@ -341,22 +341,7 @@ onMounted(() => { animation: scan 2s infinite linear; } -/* -DO NOT ATTEMPT TO USE backdrop-filter TOGETHER WITH mask-image. - -html - Why doesn't blur backdrop-filter work together with mask-image? - Stack Overflow -https://stackoverflow.com/questions/72780266/why-doesnt-blur-backdrop-filter-work-together-with-mask-image -*/ .chat-history { - --gradient: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 20%); - -webkit-mask-image: var(--gradient); - mask-image: var(--gradient); - -webkit-mask-size: 100% 100%; - mask-size: 100% 100%; - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; - -webkit-mask-position: bottom; - mask-position: bottom; max-height: 35dvh; } diff --git a/packages/stage-ui/package.json b/packages/stage-ui/package.json index 449677ae5..a65e7094a 100644 --- a/packages/stage-ui/package.json +++ b/packages/stage-ui/package.json @@ -160,6 +160,7 @@ "uuid": "catalog:", "valibot": "catalog:", "vaul-vue": "catalog:", + "virtua": "catalog:", "vue-i18n": "catalog:", "vue-router": "catalog:", "vue-sonner": "catalog:", diff --git a/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.vue b/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.vue index 3a1ca3ec3..2c9eba3ef 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.vue +++ b/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.vue @@ -20,14 +20,13 @@ import { DropdownMenuRoot, DropdownMenuTrigger, } from 'reka-ui' -import { computed, inject, reactive, ref, shallowRef, toRef, useTemplateRef, watch } from 'vue' +import { computed, reactive, ref, shallowRef, toRef, useTemplateRef, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useWebHaptics } from 'web-haptics/vue' import { createChatActionMenuItems, createChatActionMenuTriggerState } from '.' import { useBreakpoints } from '../../../../../composables/use-breakpoints' import { useElementScroll } from '../../composables/use-element-scroll' -import { chatScrollContainerKey } from '../../constants' const props = withDefaults(defineProps<{ canCopy?: boolean @@ -36,6 +35,7 @@ const props = withDefaults(defineProps<{ copyText?: string menuLabel?: string placement?: 'left' | 'right' + scrollContainer?: HTMLElement | null }>(), { canCopy: true, canRetry: false, @@ -43,6 +43,7 @@ const props = withDefaults(defineProps<{ copyText: '', menuLabel: 'Message actions', placement: 'right', + scrollContainer: null, }) const emit = defineEmits<{ @@ -58,8 +59,7 @@ const measuredElementRef = shallowRef(null) const contextMenuContainerElementRef = useTemplateRef('contextMenuContainer') const topSentinelRef = useTemplateRef('topSentinel') const bottomSentinelRef = useTemplateRef('bottomSentinel') -const injectedScrollContainer = inject(chatScrollContainerKey, undefined) -const scrollTarget = computed(() => injectedScrollContainer?.value ?? null) +const scrollTarget = computed(() => props.scrollContainer) const contextMenuOpen = shallowRef(false) const dropdownMenuOpen = shallowRef(false) const { diff --git a/packages/stage-ui/src/components/scenarios/chat/components/assistant-item.vue b/packages/stage-ui/src/components/scenarios/chat/components/assistant-item.vue index 7ebf034ce..9c632002e 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/assistant-item.vue +++ b/packages/stage-ui/src/components/scenarios/chat/components/assistant-item.vue @@ -16,11 +16,13 @@ import { createToolCallResultLookup, resolveToolCallBlockState } from './tool-ca const props = withDefaults(defineProps<{ message: ChatAssistantMessage label: string + scrollContainer?: HTMLElement | null showPlaceholder?: boolean variant?: 'desktop' | 'mobile' toolCallRenderers?: ChatToolCallRendererRegistry }>(), { showPlaceholder: false, + scrollContainer: null, variant: 'desktop', toolCallRenderers: () => ({}), }) @@ -76,7 +78,9 @@ function getToolCallRenderer(slice: ChatSlices) { const showLoader = computed(() => props.showPlaceholder && resolvedSlices.value.length === 0) const containerClass = computed(() => props.variant === 'mobile' ? 'mr-0' : 'mr-12') const boxClasses = computed(() => [ - props.variant === 'mobile' ? 'px-2 py-2 text-sm bg-primary-50/90 dark:bg-primary-950/90' : 'px-3 py-3 bg-primary-50/80 dark:bg-primary-950/80', + props.variant === 'mobile' + ? ['px-2 py-2 text-sm', 'bg-primary-50/60 backdrop-blur-xl dark:bg-primary-950/60'] + : ['px-3 py-3', 'bg-primary-50/80 dark:bg-primary-950/75'], ]) const copyText = computed(() => getChatHistoryItemCopyText(props.message as ChatHistoryItem)) @@ -86,6 +90,7 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat @@ -95,6 +100,7 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat flex="~ col" shadow="sm primary-200/50 dark:none" min-w-20 gap-2 rounded-xl h="unset (), { canRetry: false, + scrollContainer: null, showPlaceholder: false, variant: 'desktop', }) @@ -31,7 +33,9 @@ const emit = defineEmits<{ const boxClasses = computed(() => [ 'min-w-0', 'max-w-full', - props.variant === 'mobile' ? 'px-2 py-2 text-sm' : 'px-3 py-3', + props.variant === 'mobile' + ? ['px-2 py-2 text-sm', 'bg-violet-100/60 backdrop-blur-xl dark:bg-violet-950/60'] + : ['px-3 py-3', 'bg-violet-100/80 dark:bg-violet-950/80'], ]) const copyText = computed(() => getChatHistoryItemCopyText(props.message as ChatHistoryItem)) @@ -47,6 +51,7 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat :copy-text="copyText" :can-delete="!showPlaceholder" :can-retry="canRetry && !showPlaceholder" + :scroll-container="scrollContainer" @copy="emit('copy')" @retry="emit('retry')" @delete="emit('delete')" @@ -55,13 +60,13 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
diff --git a/packages/stage-ui/src/components/scenarios/chat/components/history-message-frame.vue b/packages/stage-ui/src/components/scenarios/chat/components/history-message-frame.vue new file mode 100644 index 000000000..9576da370 --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/chat/components/history-message-frame.vue @@ -0,0 +1,33 @@ + + + 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 13ebefd17..51ea8171b 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 @@ -1,99 +1,108 @@ import type { ChatHistoryItem } from '../../../../types/chat' +import en from '@proj-airi/i18n/locales/en' + import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-vue' -import { computed, defineComponent, shallowRef } from 'vue' import { createI18n } from 'vue-i18n' import ChatHistory from './history.vue' import { getChatHistoryItemKey } from '../utils' -vi.mock('../composables/use-chat-history-scroll', () => ({ - useChatHistoryScroll: () => undefined, -})) - -vi.mock('../../../markdown', () => ({ - MarkdownRenderer: defineComponent({ - name: 'MarkdownRendererStub', - props: { - content: { - type: String, - default: '', - }, - }, - template: '
{{ content }}
', - }), -})) - -function createTestI18n() { +function createEnglishI18n() { return createI18n({ legacy: false, locale: 'en', - messages: { - en: { - stage: { - chat: { - actions: { - retry: 'Retry', - }, - message: { - 'character-name': { - 'airi': 'AIRI', - 'core-system': 'System', - 'you': 'You', - }, - }, - }, - }, - }, - }, - }) -} - -function createHarness(messages: ChatHistoryItem[]) { - return defineComponent({ - name: 'ChatHistoryRetryHarness', - components: { - ChatHistory, - }, - setup() { - const lastRetryIndex = shallowRef('none') - const lastToolCallRerunPayload = shallowRef('') - - function handleRetryMessage(payload: { index: number }) { - lastRetryIndex.value = String(payload.index) - } - - function handleToolCallRerun(payload: unknown) { - lastToolCallRerunPayload.value = JSON.stringify(payload) - } - - const toolCallRerunPayload = computed(() => lastToolCallRerunPayload.value) - - return { - handleRetryMessage, - handleToolCallRerun, - lastRetryIndex, - messages, - toolCallRerunPayload, - } - }, - template: ` -
- - {{ lastRetryIndex }} - {{ toolCallRerunPayload }} -
- `, + messages: { en }, }) } describe('chat history', () => { + // ROOT CAUSE: + // + // Rendering every message keeps every backdrop-filter surface alive, even when + // most of the history is outside the viewport. Long histories then cost more to + // lay out and composite during fast mobile scrolling. + // + // We virtualize the history and mount only the viewport plus a small overscan area. + it('virtualizes long histories and reveals messages inside the viewport', async () => { + const messages: ChatHistoryItem[] = Array.from({ length: 100 }, (_, index) => ({ + id: `user-${index}`, + role: 'user', + content: `Message ${index} `.repeat(index % 6 + 1), + createdAt: index, + })) + + const screen = await render(ChatHistory, { + props: { + messages, + variant: 'mobile', + style: 'height: 240px; width: 320px; overflow-y: auto;', + }, + global: { + plugins: [createEnglishI18n()], + }, + }) + + await vi.waitFor(() => { + const renderedMessages = screen.container.querySelectorAll('.chat-message-item') + expect(renderedMessages.length).toBeGreaterThan(0) + expect(renderedMessages.length).toBeLessThan(messages.length) + expect(screen.container.textContent).toContain('Message 99') + }) + + await vi.waitFor(() => { + const visibleMessages = screen.container.querySelectorAll('.chat-message-item-visible') + const hiddenMessages = screen.container.querySelectorAll('.chat-message-item:not(.chat-message-item-visible)') + + expect(visibleMessages.length).toBeGreaterThan(0) + expect(hiddenMessages.length).toBeGreaterThan(0) + expect(visibleMessages[0].classList.contains('opacity-100')).toBe(true) + expect(visibleMessages[0].classList.contains('transition-opacity')).toBe(true) + expect(hiddenMessages[0].classList.contains('opacity-0')).toBe(true) + }) + + const history = screen.container.querySelector('.chat-history-list') + expect(history).not.toBeNull() + if (!history) + throw new Error('Expected a chat history viewport.') + + history.scrollTop = 0 + history.dispatchEvent(new Event('scroll')) + + await vi.waitFor(() => { + expect(screen.container.textContent).toContain('Message 0') + }) + }) + + it('keeps a stable mask on each mobile message container', async () => { + const screen = await render(ChatHistory, { + props: { + messages: [{ role: 'user', content: 'hello' }], + variant: 'mobile', + style: 'height: 240px; width: 320px; overflow-y: auto;', + }, + global: { + plugins: [createEnglishI18n()], + }, + }) + + await vi.waitFor(() => { + expect(screen.container.querySelector('.chat-message-item-container')).not.toBeNull() + }) + + const messageContainer = screen.container.querySelector('.chat-message-item-container') + expect(messageContainer).not.toBeNull() + if (!messageContainer) + throw new Error('Expected a mobile chat message container.') + + expect(getComputedStyle(messageContainer).maskImage).not.toBe('none') + await vi.waitFor(() => { + expect(messageContainer.closest('.chat-message-item-visible')).not.toBeNull() + }) + }) + // ROOT CAUSE: // // Cross-window synchronization can publish `sending` before it publishes the new stream. @@ -101,7 +110,7 @@ describe('chat history', () => { // // We fixed this by rendering only a stream that has the stable id assigned to the assistant turn. it('does not render the initial empty stream while a synchronized send starts', async () => { - await render(ChatHistory, { + const screen = await render(ChatHistory, { props: { messages: [], sending: true, @@ -112,60 +121,58 @@ describe('chat history', () => { tool_results: [], createdAt: 1710000000000, }, + style: 'height: 240px; width: 320px; overflow-y: auto;', }, global: { - plugins: [createTestI18n()], + plugins: [createEnglishI18n()], }, }) - expect(document.querySelectorAll('[data-chat-message-role="assistant"]')).toHaveLength(0) + expect(screen.container.querySelectorAll('.chat-message-item')).toHaveLength(0) }) - /** - * @example - * it('emits retry-message when the retry button is clicked for an error after a user message', async () => { - * const screen = await render(createHarness(messages), { global: { plugins: [createTestI18n()] } }) - * await screen.getByRole('button', { name: 'Retry' }).click() - * await expect.element(screen.getByLabelText('retry-index')).toHaveTextContent('1') - * }) - */ it('emits retry-message when the retry button is clicked for an error after a user message', async () => { const messages: ChatHistoryItem[] = [ { role: 'user', content: 'hello' }, { role: 'error', content: 'Remote sent 400 response' }, ] - const screen = await render(createHarness(messages), { + const screen = await render(ChatHistory, { + props: { + messages, + style: 'height: 480px; width: 480px; overflow-y: auto;', + }, global: { - plugins: [createTestI18n()], + plugins: [createEnglishI18n()], }, }) await screen.getByRole('button', { name: 'Retry' }).click() - await expect.element(screen.getByLabelText('retry-index')).toHaveTextContent('1') + expect(screen.emitted('retryMessage')).toEqual([[ + { + message: messages[1], + index: 1, + key: getChatHistoryItemKey(messages[1], 1), + }, + ]]) }) - /** - * @example - * it('does not render the retry button when the error is not preceded by a user message', async () => { - * const screen = await render(createHarness(messages), { global: { plugins: [createTestI18n()] } }) - * expect(document.body.textContent).not.toContain('Retry') - * }) - */ it('does not render the retry button when the error is not preceded by a user message', async () => { - const messages: ChatHistoryItem[] = [ - { role: 'assistant', content: 'hello', slices: [], tool_results: [] }, - { role: 'error', content: 'Remote sent 400 response' }, - ] - - await render(createHarness(messages), { + const screen = await render(ChatHistory, { + props: { + messages: [ + { role: 'assistant', content: 'hello', slices: [], tool_results: [] }, + { role: 'error', content: 'Remote sent 400 response' }, + ], + style: 'height: 480px; width: 480px; overflow-y: auto;', + }, global: { - plugins: [createTestI18n()], + plugins: [createEnglishI18n()], }, }) - expect(document.body.textContent).not.toContain('Retry') + expect(screen.container.textContent).not.toContain('Retry') }) it('emits tool-call-rerun with message context when a tool call rerun button is clicked', async () => { @@ -192,21 +199,27 @@ describe('chat history', () => { assistantMessage, ] - const screen = await render(createHarness(messages), { + const screen = await render(ChatHistory, { + props: { + messages, + style: 'height: 480px; width: 480px; overflow-y: auto;', + }, global: { - plugins: [createTestI18n()], + plugins: [createEnglishI18n()], }, }) await screen.getByLabelText('Re-run tool call').click() - await expect.element(screen.getByLabelText('tool-call-rerun')).toHaveTextContent(JSON.stringify({ - message: assistantMessage, - index: 1, - key: getChatHistoryItemKey(assistantMessage, 1), - toolCallId: 'call-weather', - toolName: 'weather', - args, - })) + expect(screen.emitted('toolCallRerun')).toEqual([[ + { + message: assistantMessage, + index: 1, + key: getChatHistoryItemKey(assistantMessage, 1), + toolCallId: 'call-weather', + toolName: 'weather', + args, + }, + ]]) }) }) 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 9f1287bd2..9d463f7fa 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/history.vue +++ b/packages/stage-ui/src/components/scenarios/chat/components/history.vue @@ -1,16 +1,21 @@ + + diff --git a/packages/stage-ui/src/components/scenarios/chat/components/user-item.vue b/packages/stage-ui/src/components/scenarios/chat/components/user-item.vue index 7c4061bef..76c3e3238 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/user-item.vue +++ b/packages/stage-ui/src/components/scenarios/chat/components/user-item.vue @@ -11,8 +11,10 @@ import { getChatHistoryItemCopyText } from '../utils' const props = withDefaults(defineProps<{ message: Extract label: string + scrollContainer?: HTMLElement | null variant?: 'desktop' | 'mobile' }>(), { + scrollContainer: null, variant: 'desktop', }) @@ -43,7 +45,9 @@ const containerClasses = computed(() => [ ]) const boxClasses = computed(() => [ - props.variant === 'mobile' ? 'px-2 pt-2 pb-1 text-sm bg-neutral-100/90 dark:bg-neutral-800/90' : 'px-3 pt-3 pb-2 bg-neutral-100/80 dark:bg-neutral-800/80', + props.variant === 'mobile' + ? ['px-2 pt-2 pb-1 text-sm', 'bg-neutral-100/60 backdrop-blur-xl dark:bg-neutral-800/60'] + : ['px-3 pt-3 pb-2', 'bg-neutral-100/80 dark:bg-neutral-800/80'], ]) const copyText = computed(() => getChatHistoryItemCopyText(props.message as ChatHistoryItem)) @@ -53,6 +57,7 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat @@ -62,6 +67,7 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat flex="~ col" shadow="sm neutral-200/50 dark:none" min-w-20 rounded-xl h="unset { + const item = document.createElement('div') + item.className = 'chat-message-item' + item.style.height = '120px' + item.tabIndex = 0 + item.textContent = `Message ${index}` + return item + }) + container.replaceChildren(...items) +} + +function startScrollBehavior({ + container, + messages, + scrollToIndex, +}: { + container: ShallowRef + messages: ShallowRef + scrollToIndex: (index: number, align: 'start' | 'end') => void +}) { + const scope = effectScope() + activeScopes.push(scope) + scope.run(() => { + useChatHistoryScroll({ + container, + messages, + getKey: message => message.id, + scrollToIndex, + }) + }) +} + +async function flushReactivity() { + await nextTick() + await Promise.resolve() +} + +afterEach(() => { + for (const scope of activeScopes) + scope.stop() + activeScopes.length = 0 + document.body.replaceChildren() + document.getSelection()?.removeAllRanges() +}) + +describe('useChatHistoryScroll', () => { + // ROOT CAUSE: + // + // Persisted history can hydrate after the scroll container mounts. Marking the + // initial scroll as complete while the list is empty leaves the restored history + // at its first message instead of the live edge. + // + // The initial request now waits until both the container and one message exist. + it('requests the restored history tail after delayed hydration', async () => { + const container = shallowRef(null) + const messages = shallowRef([]) + const scrollToIndex = vi.fn() + startScrollBehavior({ container, messages, scrollToIndex }) + + await flushReactivity() + expect(scrollToIndex).not.toHaveBeenCalled() + + container.value = createScrollContainer(2) + messages.value = [{ id: 'user-1' }, { id: 'assistant-1' }] + await flushReactivity() + + expect(scrollToIndex).toHaveBeenCalledWith(1, 'end') + }) + + it('aligns a new tail message to the viewport start', async () => { + const currentContainer = createScrollContainer(2) + currentContainer.scrollTop = currentContainer.scrollHeight + const container = shallowRef(currentContainer) + const messages = shallowRef([{ id: 'user-1' }, { id: 'assistant-1' }]) + const scrollToIndex = vi.fn() + startScrollBehavior({ container, messages, scrollToIndex }) + await flushReactivity() + scrollToIndex.mockClear() + + replaceMessageItems(currentContainer, 3) + messages.value = [...messages.value, { id: 'assistant-2' }] + await flushReactivity() + + expect(scrollToIndex).toHaveBeenCalledTimes(1) + expect(scrollToIndex).toHaveBeenCalledWith(2, 'start') + }) + + it('blocks a new-message scroll while the reader points at an older message', async () => { + const currentContainer = createScrollContainer(2) + currentContainer.scrollTop = currentContainer.scrollHeight + const container = shallowRef(currentContainer) + const messages = shallowRef([{ id: 'user-1' }, { id: 'assistant-1' }]) + const scrollToIndex = vi.fn() + startScrollBehavior({ container, messages, scrollToIndex }) + await flushReactivity() + scrollToIndex.mockClear() + + currentContainer.firstElementChild?.dispatchEvent(new PointerEvent('pointerover', { bubbles: true })) + replaceMessageItems(currentContainer, 3) + messages.value = [...messages.value, { id: 'assistant-2' }] + await flushReactivity() + + expect(scrollToIndex).not.toHaveBeenCalled() + }) + + it('keeps following after a layout-only scroll moves the viewport from the tail', async () => { + const currentContainer = createScrollContainer(2) + currentContainer.scrollTop = currentContainer.scrollHeight + const container = shallowRef(currentContainer) + const messages = shallowRef([{ id: 'user-1' }, { id: 'assistant-1' }]) + const scrollToIndex = vi.fn() + startScrollBehavior({ container, messages, scrollToIndex }) + await flushReactivity() + scrollToIndex.mockClear() + + currentContainer.scrollTop = 0 + currentContainer.dispatchEvent(new Event('scroll')) + replaceMessageItems(currentContainer, 3) + messages.value = [...messages.value, { id: 'assistant-2' }] + await flushReactivity() + + expect(scrollToIndex).toHaveBeenCalledWith(2, 'start') + }) + + it('stops following after a user scroll moves the viewport from the tail', async () => { + const currentContainer = createScrollContainer(2) + currentContainer.scrollTop = currentContainer.scrollHeight + const container = shallowRef(currentContainer) + const messages = shallowRef([{ id: 'user-1' }, { id: 'assistant-1' }]) + const scrollToIndex = vi.fn() + startScrollBehavior({ container, messages, scrollToIndex }) + await flushReactivity() + scrollToIndex.mockClear() + + currentContainer.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -100 })) + currentContainer.scrollTop = 0 + currentContainer.dispatchEvent(new Event('scroll')) + replaceMessageItems(currentContainer, 3) + messages.value = [...messages.value, { id: 'assistant-2' }] + await flushReactivity() + + expect(scrollToIndex).not.toHaveBeenCalled() + }) + + it('keeps a streaming tail aligned to the viewport end', async () => { + const currentContainer = createScrollContainer(1) + const container = shallowRef(currentContainer) + const messages = shallowRef([{ id: 'assistant-1' }]) + const scrollToIndex = vi.fn() + startScrollBehavior({ container, messages, scrollToIndex }) + await flushReactivity() + scrollToIndex.mockClear() + + messages.value = [{ id: 'assistant-1' }] + await flushReactivity() + + expect(scrollToIndex).toHaveBeenCalledTimes(1) + expect(scrollToIndex).toHaveBeenCalledWith(0, 'end') + }) +}) diff --git a/packages/stage-ui/src/components/scenarios/chat/composables/use-chat-history-scroll.test.ts b/packages/stage-ui/src/components/scenarios/chat/composables/use-chat-history-scroll.test.ts deleted file mode 100644 index b0b194edc..000000000 --- a/packages/stage-ui/src/components/scenarios/chat/composables/use-chat-history-scroll.test.ts +++ /dev/null @@ -1,434 +0,0 @@ -// @vitest-environment jsdom - -import type { ChatHistoryItem } from '../../../../types/chat' - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { effectScope, nextTick, ref } from 'vue' - -import { useChatHistoryScroll } from './use-chat-history-scroll' - -function createAssistantMessage(id: string, content: string, createdAt: number): ChatHistoryItem { - return { - id, - role: 'assistant', - content, - createdAt, - slices: [{ type: 'text', text: content }], - tool_results: [], - } -} - -function createUserMessage(id: string, content: string, createdAt: number): ChatHistoryItem { - return { - id, - role: 'user', - content, - createdAt, - } -} - -function setContainerScrollTo(container: HTMLElement, handler: (options?: ScrollToOptions) => void) { - Object.defineProperty(container, 'scrollTo', { - configurable: true, - value: handler as HTMLElement['scrollTo'], - }) -} - -function defineScrollMetrics(element: HTMLElement, metrics: { - clientHeight?: number - scrollHeight?: number - scrollTop?: number -}) { - let scrollTop = metrics.scrollTop ?? 0 - - Object.defineProperty(element, 'clientHeight', { - configurable: true, - get: () => metrics.clientHeight ?? 240, - }) - - Object.defineProperty(element, 'scrollHeight', { - configurable: true, - get: () => metrics.scrollHeight ?? 480, - }) - - Object.defineProperty(element, 'scrollTop', { - configurable: true, - get: () => scrollTop, - set: (value: number) => { - scrollTop = value - }, - }) -} - -async function flushDom() { - await nextTick() - await Promise.resolve() -} - -function createRequestAnimationFrameController() { - const callbacks: FrameRequestCallback[] = [] - - const stub = vi - .spyOn(window, 'requestAnimationFrame') - .mockImplementation((callback: FrameRequestCallback) => { - callbacks.push(callback) - return callbacks.length - }) - - function runNextFrame() { - const callback = callbacks.shift() - callback?.(performance.now()) - } - - function runAllFrames() { - while (callbacks.length > 0) - runNextFrame() - } - - return { - stub, - runNextFrame, - runAllFrames, - } -} - -function renderMessages(container: HTMLElement, messages: ChatHistoryItem[]) { - container.replaceChildren() - - for (const [index, message] of messages.entries()) { - const node = document.createElement('div') - node.dataset.chatMessageKey = String(message.id ?? `${message.role}:${index}`) - node.dataset.chatMessageIndex = String(index) - node.dataset.chatMessageRole = message.role - node.tabIndex = 0 - container.appendChild(node) - } -} - -afterEach(() => { - document.body.replaceChildren() - document.getSelection()?.removeAllRanges() - vi.restoreAllMocks() -}) - -describe('useChatHistoryScroll', () => { - it('scrolls on mount and scrolls a new tail into view while following the live edge', async () => { - const container = document.createElement('div') - document.body.appendChild(container) - defineScrollMetrics(container, { - clientHeight: 240, - scrollHeight: 480, - scrollTop: 240, - }) - - const initialMessages = [ - createUserMessage('user-1', 'hello', 1), - createAssistantMessage('assistant-1', 'hi', 2), - ] - - const messageList = ref(initialMessages) - renderMessages(container, messageList.value) - - const scrollIntoView = vi.fn() - const mountScrollTo = vi.fn((options?: ScrollToOptions) => { - container.scrollTop = options?.top ?? 0 - }) - HTMLElement.prototype.scrollIntoView = scrollIntoView - setContainerScrollTo(container, mountScrollTo) - const frameController = createRequestAnimationFrameController() - - const scope = effectScope() - - scope.run(() => { - useChatHistoryScroll({ - containerRef: ref(container), - messages: messageList, - getKey: message => message.id!, - }) - }) - - await flushDom() - frameController.runAllFrames() - await flushDom() - - expect(mountScrollTo).toHaveBeenCalledTimes(1) - expect(mountScrollTo).toHaveBeenCalledWith({ top: 480 }) - - const nextMessage = createAssistantMessage('assistant-2', 'new tail', 3) - messageList.value = [...messageList.value, nextMessage] - renderMessages(container, messageList.value) - - await flushDom() - - expect(scrollIntoView).toHaveBeenCalledTimes(1) - expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start' }) - - scope.stop() - }) - - it('scrolls to the bottom on mount after delayed layout settles', async () => { - const container = document.createElement('div') - document.body.appendChild(container) - - let scrollHeight = 480 - defineScrollMetrics(container, { - clientHeight: 240, - scrollHeight, - scrollTop: 0, - }) - - const messageList = ref([ - createUserMessage('user-1', 'hello', 1), - createAssistantMessage('assistant-1', 'hi', 2), - ]) - renderMessages(container, messageList.value) - - const scrollTo = vi.fn((options?: ScrollToOptions) => { - container.scrollTop = options?.top ?? 0 - }) - setContainerScrollTo(container, scrollTo) - HTMLElement.prototype.scrollIntoView = vi.fn() - - const frameController = createRequestAnimationFrameController() - - const scope = effectScope() - - scope.run(() => { - useChatHistoryScroll({ - containerRef: ref(container), - messages: messageList, - getKey: message => message.id!, - }) - }) - - await flushDom() - - scrollHeight = 1696 - defineScrollMetrics(container, { - clientHeight: 565, - scrollHeight, - scrollTop: container.scrollTop, - }) - - frameController.runAllFrames() - await flushDom() - - expect(frameController.stub).toHaveBeenCalled() - expect(scrollTo).toHaveBeenLastCalledWith({ top: 1696 }) - expect(container.scrollTop).toBe(1696) - - scope.stop() - }) - - it('blocks auto-scroll while the user is inspecting a non-tail message', async () => { - const container = document.createElement('div') - document.body.appendChild(container) - defineScrollMetrics(container, { - clientHeight: 240, - scrollHeight: 480, - scrollTop: 240, - }) - - const first = createUserMessage('user-1', 'hello', 1) - const second = createAssistantMessage('assistant-1', 'hi', 2) - const messageList = ref([first, second]) - renderMessages(container, messageList.value) - - const scrollIntoView = vi.fn() - setContainerScrollTo(container, vi.fn()) - HTMLElement.prototype.scrollIntoView = scrollIntoView - - const scope = effectScope() - const state = scope.run(() => { - return useChatHistoryScroll({ - containerRef: ref(container), - messages: messageList, - getKey: message => message.id!, - }) - }) - - await flushDom() - scrollIntoView.mockClear() - - const firstNode = container.querySelector('[data-chat-message-key="user-1"]') - expect(firstNode).not.toBeNull() - if (!firstNode) - throw new Error('Expected first chat node to exist.') - firstNode.dispatchEvent(new PointerEvent('pointerover', { bubbles: true })) - await flushDom() - - expect(state?.isInspectingHistory.value).toBe(true) - - messageList.value = [...messageList.value, createAssistantMessage('assistant-2', 'later', 3)] - renderMessages(container, messageList.value) - - await flushDom() - - expect(scrollIntoView).not.toHaveBeenCalled() - - scope.stop() - }) - - it('keeps following the conversation after auto-scrolling a user message to the top', async () => { - const container = document.createElement('div') - document.body.appendChild(container) - defineScrollMetrics(container, { - clientHeight: 240, - scrollHeight: 480, - scrollTop: 240, - }) - - const messageList = ref([ - createAssistantMessage('assistant-1', 'hello', 1), - ]) - renderMessages(container, messageList.value) - - const scrollIntoView = vi.fn(function (this: HTMLElement) { - if (this.dataset.chatMessageKey === 'user-1') - container.scrollTop = 180 - else if (this.dataset.chatMessageKey === 'assistant-2') - container.scrollTop = 260 - }) - HTMLElement.prototype.scrollIntoView = scrollIntoView - setContainerScrollTo(container, vi.fn()) - - const scope = effectScope() - - scope.run(() => { - useChatHistoryScroll({ - containerRef: ref(container), - messages: messageList, - getKey: message => message.id!, - }) - }) - - await flushDom() - scrollIntoView.mockClear() - - messageList.value = [...messageList.value, createUserMessage('user-1', 'question', 2)] - defineScrollMetrics(container, { - clientHeight: 240, - scrollHeight: 600, - scrollTop: 240, - }) - renderMessages(container, messageList.value) - await flushDom() - - expect(scrollIntoView).toHaveBeenCalledTimes(1) - expect(scrollIntoView).toHaveBeenNthCalledWith(1, { block: 'start' }) - container.dispatchEvent(new Event('scroll')) - await flushDom() - - messageList.value = [...messageList.value, createAssistantMessage('assistant-2', 'answer', 3)] - defineScrollMetrics(container, { - clientHeight: 240, - scrollHeight: 760, - scrollTop: 180, - }) - renderMessages(container, messageList.value) - await flushDom() - - expect(scrollIntoView).toHaveBeenCalledTimes(2) - expect(scrollIntoView).toHaveBeenNthCalledWith(2, { block: 'start' }) - - scope.stop() - }) - - it('treats layout-only tail drift as still following until the user manually disengages', async () => { - const container = document.createElement('div') - document.body.appendChild(container) - defineScrollMetrics(container, { - clientHeight: 240, - scrollHeight: 480, - scrollTop: 240, - }) - - const messageList = ref([ - createAssistantMessage('assistant-1', 'hello', 1), - ]) - renderMessages(container, messageList.value) - - const scrollIntoView = vi.fn() - HTMLElement.prototype.scrollIntoView = scrollIntoView - setContainerScrollTo(container, vi.fn()) - - const scope = effectScope() - - scope.run(() => { - useChatHistoryScroll({ - containerRef: ref(container), - messages: messageList, - getKey: message => message.id!, - }) - }) - - await flushDom() - scrollIntoView.mockClear() - - defineScrollMetrics(container, { - clientHeight: 180, - scrollHeight: 560, - scrollTop: 240, - }) - - messageList.value = [...messageList.value, createAssistantMessage('assistant-2', 'follow-up', 2)] - renderMessages(container, messageList.value) - await flushDom() - - expect(scrollIntoView).toHaveBeenCalledTimes(1) - expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start' }) - - scope.stop() - }) - - it('keeps following a streaming tail without top-aligning it again while the user is still following the conversation', async () => { - const container = document.createElement('div') - document.body.appendChild(container) - defineScrollMetrics(container, { - clientHeight: 240, - scrollHeight: 480, - scrollTop: 240, - }) - - const streamedMessage = createAssistantMessage('assistant-1', 'hello', 1) - const messageList = ref([streamedMessage]) - renderMessages(container, messageList.value) - - const scrollTo = vi.fn((options?: ScrollToOptions) => { - container.scrollTop = options?.top ?? 0 - }) - setContainerScrollTo(container, scrollTo) - const scrollIntoView = vi.fn() - HTMLElement.prototype.scrollIntoView = scrollIntoView - - const scope = effectScope() - - scope.run(() => { - useChatHistoryScroll({ - containerRef: ref(container), - messages: messageList, - getKey: message => message.id!, - }) - }) - - await flushDom() - scrollTo.mockClear() - - defineScrollMetrics(container, { - clientHeight: 240, - scrollHeight: 760, - scrollTop: 240, - }) - - messageList.value = [createAssistantMessage('assistant-1', 'hello there', 1)] - renderMessages(container, messageList.value) - - await flushDom() - - expect(scrollTo).toHaveBeenCalledTimes(1) - expect(scrollTo).toHaveBeenCalledWith({ top: 760 }) - expect(scrollIntoView).not.toHaveBeenCalled() - - scope.stop() - }) -}) diff --git a/packages/stage-ui/src/components/scenarios/chat/composables/use-chat-history-scroll.ts b/packages/stage-ui/src/components/scenarios/chat/composables/use-chat-history-scroll.ts index 223ec0c62..815f790a3 100644 --- a/packages/stage-ui/src/components/scenarios/chat/composables/use-chat-history-scroll.ts +++ b/packages/stage-ui/src/components/scenarios/chat/composables/use-chat-history-scroll.ts @@ -1,427 +1,149 @@ -import type { Ref } from 'vue' +import type { Ref, ShallowRef } from 'vue' -import { computed, nextTick, onScopeDispose, readonly, shallowRef, watch } from 'vue' - -// NOTICE: Keep a small tolerance for "near tail" detection so sub-pixel layout shifts, -// font swaps, and late content growth do not falsely disengage follow mode. -const TAIL_THRESHOLD = 24 - -function scheduleAfterLayoutSettles(task: () => void) { - const requestFrame = globalThis.requestAnimationFrame?.bind(globalThis) - if (!requestFrame) { - queueMicrotask(task) - return - } - - requestFrame(() => { - requestFrame(() => { - task() - }) - }) -} +import { useEventListener } from '@vueuse/core' +import { computed, watch } from 'vue' interface ChatHistoryScrollOptions { - /** - * The scroll container that owns the chat history viewport. - * - * Use this when the composable should manage scroll state for a specific - * `
` or similar scrolling element. The element must be the same node - * that receives the rendered `[data-chat-message-key]` children, because the - * composable both measures the container and queries message elements inside it. - * - * In practice, pass a template ref from the chat history list component: - * - * ```ts - * const chatHistoryRef = ref() - * - * useChatHistoryScroll({ - * containerRef: chatHistoryRef, - * messages, - * getKey, - * }) - * ``` - */ - containerRef: Ref - /** - * The ordered chat history currently rendered inside the container. - * - * Use this when the message list is reactive and new items or streaming updates - * can arrive after mount. The composable compares the current tail key with the - * previous tail key to distinguish between: - * - * - a genuinely new tail message - * - more content being appended to the existing tail message - * - * Pass the exact list that the UI renders, including temporary or streaming - * placeholders if those appear in the chat history surface. - */ - messages: Ref - /** - * Returns the stable rendered identity for a message at a given index. - * - * Use this when messages have IDs, timestamps, or another stable identity that - * matches the DOM node's `data-chat-message-key`. The composable relies on this - * key for two behaviors: - * - * - detecting whether the tail changed between updates - * - locating the newly inserted tail element to align it into view - * - * The returned key should be stable for the lifetime of a rendered message. - * If the key changes while representing the same message, the composable will - * treat that as a new tail insertion and may scroll unexpectedly. - */ + container: Readonly> + messages: Readonly> getKey: (message: TMessage, index: number) => string | number - /** - * Optional policy hook for vetoing auto-scroll on new tail insertions. - * - * Use this when product behavior needs one more decision layer beyond the - * composable's built-in intent tracking. For example, a caller might suppress - * auto-scroll for a certain role, for a synthetic system row, or while a - * separate overlay is active. - * - * This hook is only consulted for genuinely new tail messages. It is not used - * for initial mount scroll or for streaming follow of the current tail. - * - * Return `false` to block the auto-scroll. Any other return value allows it. - */ - shouldScroll?: (context: { - reason: 'new-message' - messageKey: string | number - role?: string - isFollowingTail: boolean - isInspectingHistory: boolean - }) => boolean + scrollToIndex: (index: number, align: 'start' | 'end') => void } /** - * Keeps chat history scrolling aligned with user intent instead of raw message churn. + * Keeps chat history scrolling aligned with the reader's current intent. * - * Design purpose: - * - * - Show the latest history on first mount, even if the final layout settles a bit later. - * - Follow a live conversation while the user is still reading at the tail. - * - Stop automatic movement once the user starts inspecting older history. - * - Distinguish a newly inserted tail message from streaming growth of the same tail. - * - Align newly inserted messages to their top edge so long replies start in view. - * - * When to use: - * - * Use this composable for vertically scrolling chat or timeline surfaces where the - * latest item normally appears at the bottom and the UI should remain polite about - * moving the viewport. It is a good fit when messages can arrive from local input, - * remote sync, IPC, streaming generation, or any other reactive source. - * - * How to use: - * - * 1. Render the history inside a single scrolling container. - * 2. Add `data-chat-message-key` to each rendered message wrapper. - * 3. Pass the container ref, rendered message list, and stable key getter. - * 4. Optionally provide `shouldScroll` if the caller needs extra veto logic. - * - * The composable tracks several signals of user intent, including tail proximity, - * pointer/focus inspection of older messages, and text selection in history. - * Automatic follow is preserved only while those signals still indicate that the - * user wants to stay with the live edge. + * A user scroll away from the tail disables automatic movement. Layout changes + * and index scrolls do not disable it. Pointer, focus, and selection on an older + * message also block movement until that inspection ends. */ -export function useChatHistoryScroll({ - containerRef, +export function useChatHistoryScroll({ + container, messages, getKey, - shouldScroll, + scrollToIndex, }: ChatHistoryScrollOptions) { - const isFollowingTail = shallowRef(true) - const isFollowingConversation = shallowRef(true) - const isInspectingOlderMessage = shallowRef(false) - const isSelectionInspectingHistory = shallowRef(false) - const isInspectingHistory = computed(() => !isFollowingTail.value || isInspectingOlderMessage.value || isSelectionInspectingHistory.value) - const pendingScrollKey = shallowRef(null) - const pendingStreamingFollow = shallowRef(false) - const previousLastMessageKey = shallowRef(null) - const stopListening = shallowRef<(() => void) | null>(null) - const didInitialScroll = shallowRef(false) - const isProgrammaticScroll = shallowRef(false) + let didRequestInitialScroll = false + let hasUserScrollIntent = false + let isFollowingConversation = true + let isFollowingTail = true + let isPointerOrFocusOnOlderMessage = false + let isSelectionInOlderMessage = false + let previousContainer: HTMLElement | null = null + let previousLastMessageKey: string | number | null = null - function getContainer() { - return containerRef.value + const selectionDocument = computed(() => container.value?.ownerDocument) + + const isNearTail = (currentContainer: HTMLElement) => { + // NOTICE: This tolerance absorbs sub-pixel layout changes, font swaps, and late content growth. + return currentContainer.scrollTop + currentContainer.clientHeight >= currentContainer.scrollHeight - 24 } - function getLastMessageKey() { - const lastIndex = messages.value.length - 1 - if (lastIndex < 0) - return null - - return getKey(messages.value[lastIndex], lastIndex) - } - - /** - * Keep chat auto-scroll tied to user intent instead of raw data churn. - * - * Criteria: - * - Scroll to the bottom once on mount so the latest history is visible initially. - * - Only auto-scroll when a genuinely new tail message is inserted. - * - Never treat streaming growth of the current tail message like a new tail insertion; - * keep bottom-follow only while the user is already following the conversation. - * - Only follow the live edge while the user is already near the tail. - * - Stop automatic movement while the user is inspecting older messages through - * scrolling, pointer interaction, focus, or text selection. - * - Scroll new messages to their top edge so the beginning of long replies stays visible. - * - * This is especially important in Electron, where the chat list can be updated by - * external synced sources and broadcast events, not just by the local input area. - */ - function isNearTail(container: HTMLElement) { - // A small threshold keeps "follow live edge" stable when layout and content height shift slightly. - return container.scrollTop + container.clientHeight >= container.scrollHeight - TAIL_THRESHOLD - } - - function updateFollowingTail() { - const container = getContainer() - if (!container) { - isFollowingTail.value = true - return - } - - isFollowingTail.value = isNearTail(container) - } - - function disengageConversationFollow() { - isFollowingConversation.value = false - } - - function syncConversationFollowFromTail() { - if (isFollowingTail.value) - isFollowingConversation.value = true - } - - function findMessageElement(target: EventTarget | Node | null) { + const findMessageItem = (target: EventTarget | Node | null) => { if (!(target instanceof Node)) return null - const container = getContainer() - if (!container) - return null - + const currentContainer = container.value const element = target instanceof Element ? target : target.parentElement - if (!element) - return null - - return element.closest('[data-chat-message-key]') + const messageItem = element?.closest('.chat-message-item') ?? null + return messageItem && currentContainer?.contains(messageItem) ? messageItem : null } - function isLastMessageElement(element: HTMLElement | null) { - return element?.dataset.chatMessageKey === `${getLastMessageKey() ?? ''}` + const isOlderMessageItem = (messageItem: HTMLElement | null) => { + if (!messageItem || !isFollowingTail) + return !!messageItem + + const messageItems = container.value?.querySelectorAll('.chat-message-item') + return messageItem !== messageItems?.item((messageItems.length ?? 0) - 1) } - function syncPointerOrFocusInspection(target: EventTarget | null) { - const element = findMessageElement(target) - isInspectingOlderMessage.value = !!element && !isLastMessageElement(element) - } - - function syncSelectionInspection() { - const selection = document.getSelection() - if (!selection?.anchorNode) { - isSelectionInspectingHistory.value = false - return - } - - const element = findMessageElement(selection.anchorNode) - isSelectionInspectingHistory.value = !!element && !isLastMessageElement(element) - } - - function scrollToBottom() { - const container = getContainer() - if (!container) + useEventListener(container, 'scroll', () => { + const currentContainer = container.value + if (!currentContainer) return - isProgrammaticScroll.value = true - container.scrollTo({ top: container.scrollHeight }) - nextTick(() => { - isProgrammaticScroll.value = false - updateFollowingTail() - syncConversationFollowFromTail() - }) - } - - function findMessageElementByKey(key: string | number) { - const container = getContainer() - if (!container) - return null - - const messageElements = Array.from(container.querySelectorAll('[data-chat-message-key]')) - for (const element of messageElements) { - if (element.dataset.chatMessageKey === `${key}`) - return element + isFollowingTail = isNearTail(currentContainer) + if (isFollowingTail) { + isFollowingConversation = true + hasUserScrollIntent = false + if (!isSelectionInOlderMessage) + isPointerOrFocusOnOlderMessage = false } - - return null - } - - function bindContainer(container: HTMLDivElement) { - const handleScroll = () => { - updateFollowingTail() - if (!isFollowingTail.value && !isProgrammaticScroll.value) - disengageConversationFollow() - else - syncConversationFollowFromTail() - - if (isFollowingTail.value && !isSelectionInspectingHistory.value) - isInspectingOlderMessage.value = false + else if (hasUserScrollIntent) { + isFollowingConversation = false } + }, { passive: true }) - const handlePointerOver = (event: Event) => { - syncPointerOrFocusInspection(event.target) - } + useEventListener(container, ['wheel', 'touchmove'], () => { + hasUserScrollIntent = true + }, { passive: true }) - const handlePointerOut = (event: Event) => { - const relatedTarget = event instanceof PointerEvent ? event.relatedTarget : null - syncPointerOrFocusInspection(relatedTarget) - } + useEventListener(container, 'keydown', (event) => { + if (['ArrowDown', 'ArrowUp', 'End', 'Home', 'PageDown', 'PageUp', ' '].includes(event.key)) + hasUserScrollIntent = true + }) - const handleFocusIn = (event: FocusEvent) => { - syncPointerOrFocusInspection(event.target) - } + useEventListener(container, 'pointerover', (event) => { + isPointerOrFocusOnOlderMessage = isOlderMessageItem(findMessageItem(event.target)) + }) + useEventListener(container, 'pointerout', (event) => { + isPointerOrFocusOnOlderMessage = isOlderMessageItem(findMessageItem(event.relatedTarget)) + }) + useEventListener(container, 'focusin', (event) => { + isPointerOrFocusOnOlderMessage = isOlderMessageItem(findMessageItem(event.target)) + }) + useEventListener(container, 'focusout', (event) => { + isPointerOrFocusOnOlderMessage = isOlderMessageItem(findMessageItem(event.relatedTarget)) + }) + useEventListener(selectionDocument, 'selectionchange', () => { + const selection = selectionDocument.value?.getSelection() + isSelectionInOlderMessage = isOlderMessageItem(findMessageItem(selection?.anchorNode ?? null)) + }) - const handleFocusOut = (event: FocusEvent) => { - syncPointerOrFocusInspection(event.relatedTarget) - } + watch( + [container, messages], + ([currentContainer, currentMessages]) => { + if (currentContainer !== previousContainer) { + previousContainer = currentContainer + previousLastMessageKey = null + didRequestInitialScroll = false + hasUserScrollIntent = false + isFollowingConversation = true + isFollowingTail = currentContainer ? isNearTail(currentContainer) : true + isPointerOrFocusOnOlderMessage = false + isSelectionInOlderMessage = false + } - const handleSelectionChange = () => { - syncSelectionInspection() - } - - container.addEventListener('scroll', handleScroll, { passive: true }) - container.addEventListener('pointerover', handlePointerOver) - container.addEventListener('pointerout', handlePointerOut) - container.addEventListener('focusin', handleFocusIn) - container.addEventListener('focusout', handleFocusOut) - document.addEventListener('selectionchange', handleSelectionChange) - - stopListening.value = () => { - container.removeEventListener('scroll', handleScroll) - container.removeEventListener('pointerover', handlePointerOver) - container.removeEventListener('pointerout', handlePointerOut) - container.removeEventListener('focusin', handleFocusIn) - container.removeEventListener('focusout', handleFocusOut) - document.removeEventListener('selectionchange', handleSelectionChange) - } - } - - watch(containerRef, (container) => { - stopListening.value?.() - stopListening.value = null - - if (!container) - return - - bindContainer(container) - updateFollowingTail() - syncConversationFollowFromTail() - syncSelectionInspection() - - if (!didInitialScroll.value) { - didInitialScroll.value = true - nextTick(() => { - scheduleAfterLayoutSettles(() => { - scrollToBottom() - }) - }) - } - }, { immediate: true }) - - watch(messages, (currentMessages) => { - const currentLastIndex = currentMessages.length - 1 - if (currentLastIndex < 0) { - previousLastMessageKey.value = null - pendingScrollKey.value = null - isInspectingOlderMessage.value = false - isSelectionInspectingHistory.value = false - return - } - - const currentLastMessage = currentMessages[currentLastIndex] - const currentLastKey = getKey(currentLastMessage, currentLastIndex) - const previousTailKey = previousLastMessageKey.value - previousLastMessageKey.value = currentLastKey - - // The last key change is the boundary between "a new message arrived" and "the current tail - // is still streaming more content". Only the first case is allowed to move the viewport. - if (previousTailKey == null) { - pendingScrollKey.value = null - pendingStreamingFollow.value = false - return - } - - if (previousTailKey === currentLastKey) { - pendingScrollKey.value = null - if (!isFollowingConversation.value || isInspectingOlderMessage.value || isSelectionInspectingHistory.value) { - pendingStreamingFollow.value = false + const lastIndex = currentMessages.length - 1 + if (!currentContainer || lastIndex < 0) { + previousLastMessageKey = null + didRequestInitialScroll = false return } - pendingStreamingFollow.value = true - return - } + const currentLastMessageKey = getKey(currentMessages[lastIndex], lastIndex) + if (!didRequestInitialScroll) { + didRequestInitialScroll = true + previousLastMessageKey = currentLastMessageKey + scrollToIndex(lastIndex, 'end') + return + } - if (!isFollowingConversation.value || isInspectingOlderMessage.value || isSelectionInspectingHistory.value) { - pendingScrollKey.value = null - pendingStreamingFollow.value = false - return - } + const previousKey = previousLastMessageKey + previousLastMessageKey = currentLastMessageKey + const isInspectingHistory = isPointerOrFocusOnOlderMessage || isSelectionInOlderMessage - const shouldScrollResult = shouldScroll?.({ - reason: 'new-message', - messageKey: currentLastKey, - role: currentLastMessage.role, - isFollowingTail: isFollowingConversation.value, - isInspectingHistory: isInspectingOlderMessage.value || isSelectionInspectingHistory.value, - }) - if (shouldScrollResult === false) { - pendingScrollKey.value = null - pendingStreamingFollow.value = false - return - } + if (!isFollowingConversation || isInspectingHistory) + return - pendingScrollKey.value = currentLastKey - pendingStreamingFollow.value = false - }, { deep: false, immediate: true }) + if (previousKey === currentLastMessageKey) { + scrollToIndex(lastIndex, 'end') + return + } - watch(pendingScrollKey, async (messageKey) => { - if (messageKey == null) - return - - await nextTick() - - const target = findMessageElementByKey(messageKey) - pendingScrollKey.value = null - if (!target) - return - - // Align to the top of the new message so the start of a long reply remains visible. - isProgrammaticScroll.value = true - target.scrollIntoView({ block: 'start' }) - nextTick(() => { - isProgrammaticScroll.value = false - isFollowingConversation.value = true - updateFollowingTail() - }) - }, { flush: 'post' }) - - watch(pendingStreamingFollow, async (shouldFollow) => { - if (!shouldFollow) - return - - await nextTick() - pendingStreamingFollow.value = false - scrollToBottom() - }, { flush: 'post' }) - - onScopeDispose(() => { - stopListening.value?.() - }) - - return { - isFollowingTail: readonly(isFollowingTail), - isInspectingHistory: readonly(isInspectingHistory), - scrollToBottom, - } + if (previousKey != null) + scrollToIndex(lastIndex, 'start') + }, + { flush: 'post', immediate: true }, + ) } diff --git a/packages/stage-ui/src/components/scenarios/chat/composables/use-chat-history-top-fade.ts b/packages/stage-ui/src/components/scenarios/chat/composables/use-chat-history-top-fade.ts new file mode 100644 index 000000000..672f28895 --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/chat/composables/use-chat-history-top-fade.ts @@ -0,0 +1,80 @@ +import type { Ref, ShallowRef } from 'vue' + +import { useEventListener, useMutationObserver, useRafFn, useResizeObserver } from '@vueuse/core' +import { shallowRef, watch } from 'vue' + +function useObservedElements( + root: Readonly>, + selector: string, +) { + const elements = shallowRef([]) + + const collectElements = () => { + const currentRoot = root.value + elements.value = currentRoot + ? Array.from(currentRoot.querySelectorAll(selector)) + : [] + } + + watch(root, collectElements, { flush: 'post', immediate: true }) + useMutationObserver(root, collectElements, { + childList: true, + subtree: true, + }) + + return elements +} + +/** + * Updates the mask stops for the message containers inside one chat viewport. + * + * The mask stays mounted during scrolling. One animation-frame callback combines + * all scroll, resize, and virtualized-child changes before it reads layout. + */ +export function useChatHistoryTopFade({ + container, + fadeRatio, +}: { + container: Readonly> + fadeRatio: Readonly> +}) { + const messageContainers = useObservedElements(container, '.chat-message-item-container') + + const { resume: updateOnNextFrame } = useRafFn(() => { + const currentContainer = container.value + if (!currentContainer) + return + + const fadeHeight = currentContainer.clientHeight * fadeRatio.value + const containerTop = currentContainer.getBoundingClientRect().top + + for (const messageContainer of messageContainers.value) { + const messageTop = messageContainer.getBoundingClientRect().top - containerTop + + // NOTICE: + // Keep the mask declaration mounted while a message crosses the scroll boundary. + // Chromium can flash when backdrop-filter and mask layers change during a scroll frame. + // Source: https://issues.chromium.org/issues/483220231. + // Remove this workaround when browsers provide stable spatial blur without mask layers. + const transparentStop = fadeHeight > 0 && messageTop < fadeHeight ? -messageTop : -1 + const opaqueStop = fadeHeight > 0 && messageTop < fadeHeight ? fadeHeight - messageTop : 0 + messageContainer.style.setProperty('--chat-top-fade-transparent-stop', `${transparentStop}px`) + messageContainer.style.setProperty('--chat-top-fade-opaque-stop', `${opaqueStop}px`) + } + }, { immediate: false, once: true }) + + useEventListener(container, 'scroll', updateOnNextFrame, { passive: true }) + useResizeObserver( + () => { + const currentContainer = container.value + return currentContainer + ? [currentContainer, ...messageContainers.value] + : messageContainers.value + }, + updateOnNextFrame, + ) + watch([messageContainers, fadeRatio], updateOnNextFrame, { + flush: 'post', + immediate: true, + }) +} diff --git a/packages/stage-ui/src/components/scenarios/chat/composables/use-virtualizer-scroll.ts b/packages/stage-ui/src/components/scenarios/chat/composables/use-virtualizer-scroll.ts new file mode 100644 index 000000000..9c6b419e3 --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/chat/composables/use-virtualizer-scroll.ts @@ -0,0 +1,56 @@ +import type { VirtualizerHandle } from 'virtua/vue' +import type { ShallowRef } from 'vue' + +import { useRafFn } from '@vueuse/core' +import { shallowRef } from 'vue' + +interface VirtualScrollRequest { + align: 'start' | 'end' + index: number +} + +/** + * Queues the latest index scroll until Virtua has measured its viewport. + * + * Virtua exposes its component handle before its internal ResizeObserver stores + * a non-zero viewport size. This adapter polls only while one request waits for that value. + */ +export function useVirtualizerScroll( + virtualizer: Readonly>, +) { + let didObserveReadyFrame = false + const pendingRequest = shallowRef() + const { pause, resume } = useRafFn(() => { + const currentVirtualizer = virtualizer.value + const request = pendingRequest.value + if (!request) { + pause() + return + } + + if (!currentVirtualizer || currentVirtualizer.viewportSize <= 0) { + didObserveReadyFrame = false + return + } + + // Virtua schedules its item measurements after it stores viewportSize. + // Wait for one complete frame with a measured viewport before issuing the command. + if (!didObserveReadyFrame) { + didObserveReadyFrame = true + return + } + + currentVirtualizer.scrollToIndex(request.index, { align: request.align }) + pendingRequest.value = undefined + didObserveReadyFrame = false + pause() + }, { immediate: false }) + + return { + scrollToIndex(index: number, align: 'start' | 'end') { + pendingRequest.value = { align, index } + didObserveReadyFrame = false + resume() + }, + } +} diff --git a/packages/stage-ui/src/components/scenarios/chat/constants.ts b/packages/stage-ui/src/components/scenarios/chat/constants.ts deleted file mode 100644 index 4cd22f88a..000000000 --- a/packages/stage-ui/src/components/scenarios/chat/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { InjectionKey, Ref } from 'vue' - -export const chatScrollContainerKey = Symbol('chat-scroll-container') as InjectionKey> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dbe0106c..8ffd303c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1086,6 +1086,9 @@ catalogs: vieval: specifier: ^0.0.5 version: 0.0.5 + virtua: + specifier: ^0.50.3 + version: 0.50.3 vite: specifier: ^8.0.8 version: 8.0.8 @@ -4492,6 +4495,9 @@ importers: vaul-vue: specifier: 'catalog:' version: 0.4.1(reka-ui@2.10.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + virtua: + specifier: 'catalog:' + version: 0.50.3(react@19.2.3)(vue@3.5.32(typescript@5.9.3)) vue-i18n: specifier: 'catalog:' version: 11.3.2(vue@3.5.32(typescript@5.9.3)) @@ -19341,6 +19347,32 @@ packages: resolution: {integrity: sha512-PJ8A+nGD+G9ymYtZUdBEGX5qaYokNoMqvybAnWr2avhUB8giR5wUUC0sCAkB78Sbgt0TgBun0dU6EoaknDQ2sA==} hasBin: true + virtua@0.50.3: + resolution: {integrity: sha512-1/5QnIIIMn2ZPhoE3V/OsiOVGroK+2KYzgMeWz8JyAM29pLRoDoQjLXgHl5HdukVc7DXVEWEzSd61wiWOnhg6w==} + peerDependencies: + '@angular/common': '>=20.0.0' + '@angular/core': '>=20.0.0' + react: '>=16.14.0' + react-dom: '>=16.14.0' + solid-js: '>=1.0' + svelte: '>=5.0' + vue: '>=3.2' + peerDependenciesMeta: + '@angular/common': + optional: true + '@angular/core': + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + vite-bundle-visualizer@1.2.1: resolution: {integrity: sha512-cwz/Pg6+95YbgIDp+RPwEToc4TKxfsFWSG/tsl2DSZd9YZicUag1tQXjJ5xcL7ydvEoaC2FOZeaXOU60t9BRXw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -35609,6 +35641,11 @@ snapshots: - tsx - yaml + virtua@0.50.3(react@19.2.3)(vue@3.5.32(typescript@5.9.3)): + optionalDependencies: + react: 19.2.3 + vue: 3.5.32(typescript@5.9.3) + vite-bundle-visualizer@1.2.1(rolldown@1.0.0-rc.16)(rollup@2.80.0): dependencies: cac: 6.7.14 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fc8c8fefd..f1d009e5b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -398,6 +398,7 @@ catalog: vaul-vue: ^0.4.1 vec3: ^0.2.0 vieval: ^0.0.5 + virtua: ^0.50.3 vite: ^8.0.8 vite-bundle-visualizer: ^1.2.1 vite-plugin-inspect: 12.0.0-beta.1