perf(stage-ui): virtualize chat history (#2334)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
"uuid": "catalog:",
|
||||
"valibot": "catalog:",
|
||||
"vaul-vue": "catalog:",
|
||||
"virtua": "catalog:",
|
||||
"vue-i18n": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
"vue-sonner": "catalog:",
|
||||
|
||||
@@ -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<HTMLElement | null>(null)
|
||||
const contextMenuContainerElementRef = useTemplateRef<HTMLElement>('contextMenuContainer')
|
||||
const topSentinelRef = useTemplateRef<HTMLDivElement>('topSentinel')
|
||||
const bottomSentinelRef = useTemplateRef<HTMLDivElement>('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 {
|
||||
|
||||
@@ -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))
|
||||
</script>
|
||||
@@ -86,6 +90,7 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
|
||||
<ChatActionMenu
|
||||
:copy-text="copyText"
|
||||
:can-delete="!showPlaceholder"
|
||||
:scroll-container="scrollContainer"
|
||||
@copy="emit('copy')"
|
||||
@delete="emit('delete')"
|
||||
>
|
||||
@@ -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 <sm:fit"
|
||||
:class="[
|
||||
'chat-message-item-container',
|
||||
boxClasses,
|
||||
(isStageWeb() || isStageCapacitor()) && props.variant === 'mobile' ? 'select-none sm:select-auto' : '',
|
||||
]"
|
||||
|
||||
@@ -13,11 +13,13 @@ const props = withDefaults(defineProps<{
|
||||
message: ErrorMessage
|
||||
label: string
|
||||
retryLabel?: string
|
||||
scrollContainer?: HTMLElement | null
|
||||
canRetry?: boolean
|
||||
showPlaceholder?: boolean
|
||||
variant?: 'desktop' | 'mobile'
|
||||
}>(), {
|
||||
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))
|
||||
</script>
|
||||
@@ -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
|
||||
<div
|
||||
:ref="setMeasuredElement"
|
||||
:class="[
|
||||
'chat-message-item-container',
|
||||
boxClasses,
|
||||
'relative',
|
||||
'flex flex-col',
|
||||
'min-w-20 rounded-xl',
|
||||
'h-unset <sm:h-fit',
|
||||
'shadow-sm shadow-violet-200/50 dark:shadow-none',
|
||||
'bg-violet-100/80 dark:bg-violet-950/80',
|
||||
(isStageWeb() || isStageCapacitor()) && props.variant === 'mobile' ? 'select-none sm:select-auto' : '',
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { useElementVisibility } from '@vueuse/core'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
scrollContainer?: HTMLElement | null
|
||||
variant?: 'desktop' | 'mobile'
|
||||
}>(), {
|
||||
scrollContainer: null,
|
||||
variant: 'desktop',
|
||||
})
|
||||
|
||||
const messageRef = useTemplateRef<HTMLDivElement>('message')
|
||||
const scrollTarget = computed(() => props.scrollContainer)
|
||||
const isVisible = useElementVisibility(messageRef, {
|
||||
initialValue: false,
|
||||
scrollTarget,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="message"
|
||||
:class="[
|
||||
'chat-message-item',
|
||||
'opacity-0 transition-opacity duration-200 ease-out motion-reduce:transition-none',
|
||||
isVisible ? 'chat-message-item-visible opacity-100' : '',
|
||||
variant === 'mobile' ? 'pb-1' : 'pb-2',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
+131
-118
@@ -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: '<div>{{ content }}</div>',
|
||||
}),
|
||||
}))
|
||||
|
||||
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: `
|
||||
<div>
|
||||
<ChatHistory
|
||||
:messages="messages"
|
||||
@retry-message="handleRetryMessage"
|
||||
@tool-call-rerun="handleToolCallRerun"
|
||||
/>
|
||||
<output aria-label="retry-index">{{ lastRetryIndex }}</output>
|
||||
<output aria-label="tool-call-rerun">{{ toolCallRerunPayload }}</output>
|
||||
</div>
|
||||
`,
|
||||
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<HTMLElement>('.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<HTMLElement>('.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,
|
||||
},
|
||||
]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { VirtualizerHandle } from 'virtua/vue'
|
||||
|
||||
import type { ChatHistoryItem, StreamingAssistantMessage } from '../../../../types/chat'
|
||||
import type { ChatToolCallRendererRegistry } from './tool-call-renderer'
|
||||
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { Virtualizer } from 'virtua/vue'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ChatAssistantItem from './assistant-item.vue'
|
||||
import ChatErrorItem from './error-item.vue'
|
||||
import ChatHistoryMessageFrame from './history-message-frame.vue'
|
||||
import ChatUserItem from './user-item.vue'
|
||||
|
||||
import { useChatHistoryScroll } from '../composables/use-chat-history-scroll'
|
||||
import { chatScrollContainerKey } from '../constants'
|
||||
import { useChatHistoryTopFade } from '../composables/use-chat-history-top-fade'
|
||||
import { useVirtualizerScroll } from '../composables/use-virtualizer-scroll'
|
||||
import { getChatHistoryItemKey } from '../utils'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -36,8 +41,12 @@ const emit = defineEmits<{
|
||||
(e: 'toolCallRerun', payload: { message: ChatHistoryItem, index: number, key: string | number, toolCallId: string, toolName: string, args: string }): void
|
||||
}>()
|
||||
|
||||
const chatHistoryRef = ref<HTMLDivElement>()
|
||||
provide(chatScrollContainerKey, chatHistoryRef)
|
||||
/** Keeps about two mobile viewports ready so fast flicks do not expose an unmounted gap. */
|
||||
const CHAT_HISTORY_OVERSCAN = 600
|
||||
|
||||
const chatHistoryRef = useTemplateRef<HTMLDivElement>('chatHistory')
|
||||
const virtualizerRef = useTemplateRef<VirtualizerHandle>('virtualizer')
|
||||
const { scrollToIndex } = useVirtualizerScroll(virtualizerRef)
|
||||
|
||||
const { t } = useI18n()
|
||||
const labels = computed(() => ({
|
||||
@@ -66,11 +75,17 @@ const renderMessages = computed<ChatHistoryItem[]>(() => {
|
||||
|
||||
return [...props.messages, streaming.value]
|
||||
})
|
||||
const topFadeRatio = computed(() => props.variant === 'mobile' ? 0.2 : 0)
|
||||
|
||||
useChatHistoryScroll({
|
||||
containerRef: chatHistoryRef,
|
||||
container: chatHistoryRef,
|
||||
messages: renderMessages,
|
||||
getKey: getChatHistoryItemKey,
|
||||
scrollToIndex,
|
||||
})
|
||||
useChatHistoryTopFade({
|
||||
container: chatHistoryRef,
|
||||
fadeRatio: topFadeRatio,
|
||||
})
|
||||
|
||||
function emitCopyMessage(message: ChatHistoryItem, index: number) {
|
||||
@@ -112,45 +127,82 @@ function emitToolCallRerun(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="chatHistoryRef" v-auto-animate flex="~ col" relative h-full w-full overflow-y-auto rounded-xl px="<sm:2" py="<sm:2" :class="variant === 'mobile' ? 'gap-1' : 'gap-2'">
|
||||
<template v-for="(message, index) in renderMessages" :key="getChatHistoryItemKey(message, index)">
|
||||
<div
|
||||
:data-chat-message-index="index"
|
||||
:data-chat-message-key="String(getChatHistoryItemKey(message, index))"
|
||||
:data-chat-message-role="message.role"
|
||||
>
|
||||
<ChatErrorItem
|
||||
v-if="message.role === 'error'"
|
||||
:message="message"
|
||||
:label="labels.error"
|
||||
:retry-label="labels.retry"
|
||||
:can-retry="renderMessages[index - 1]?.role === 'user'"
|
||||
:show-placeholder="sending && index === renderMessages.length - 1"
|
||||
<div
|
||||
ref="chatHistory"
|
||||
:class="[
|
||||
'chat-history-list',
|
||||
'relative h-full w-full overflow-y-auto rounded-xl',
|
||||
'<sm:px-2 <sm:py-2',
|
||||
variant === 'mobile' ? 'chat-history-list--mobile' : '',
|
||||
]"
|
||||
>
|
||||
<Virtualizer
|
||||
ref="virtualizer"
|
||||
:data="renderMessages"
|
||||
:buffer-size="CHAT_HISTORY_OVERSCAN"
|
||||
>
|
||||
<template #default="{ item: message, index }">
|
||||
<ChatHistoryMessageFrame
|
||||
:key="getChatHistoryItemKey(message, index)"
|
||||
:variant="variant"
|
||||
@copy="emitCopyMessage(message, index)"
|
||||
@retry="emitRetryMessage(message, index)"
|
||||
@delete="emitDeleteMessage(message, index)"
|
||||
/>
|
||||
<ChatAssistantItem
|
||||
v-else-if="message.role === 'assistant'"
|
||||
:message="message"
|
||||
:label="labels.assistant"
|
||||
:show-placeholder="shouldShowPlaceholder(message) && showStreamingPlaceholder"
|
||||
:variant="variant"
|
||||
:tool-call-renderers="toolCallRenderers"
|
||||
@copy="emitCopyMessage(message, index)"
|
||||
@delete="emitDeleteMessage(message, index)"
|
||||
@tool-call-rerun="emitToolCallRerun(message, index, $event)"
|
||||
/>
|
||||
<ChatUserItem
|
||||
v-else-if="message.role === 'user'"
|
||||
:message="message"
|
||||
:label="labels.user"
|
||||
:variant="variant"
|
||||
@copy="emitCopyMessage(message, index)"
|
||||
@delete="emitDeleteMessage(message, index)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
:scroll-container="chatHistoryRef"
|
||||
>
|
||||
<ChatErrorItem
|
||||
v-if="message.role === 'error'"
|
||||
:message="message"
|
||||
:label="labels.error"
|
||||
:retry-label="labels.retry"
|
||||
:can-retry="renderMessages[index - 1]?.role === 'user'"
|
||||
:show-placeholder="sending && index === renderMessages.length - 1"
|
||||
:scroll-container="chatHistoryRef"
|
||||
:variant="variant"
|
||||
@copy="emitCopyMessage(message, index)"
|
||||
@retry="emitRetryMessage(message, index)"
|
||||
@delete="emitDeleteMessage(message, index)"
|
||||
/>
|
||||
<ChatAssistantItem
|
||||
v-else-if="message.role === 'assistant'"
|
||||
:message="message"
|
||||
:label="labels.assistant"
|
||||
:show-placeholder="shouldShowPlaceholder(message) && showStreamingPlaceholder"
|
||||
:scroll-container="chatHistoryRef"
|
||||
:variant="variant"
|
||||
:tool-call-renderers="toolCallRenderers"
|
||||
@copy="emitCopyMessage(message, index)"
|
||||
@delete="emitDeleteMessage(message, index)"
|
||||
@tool-call-rerun="emitToolCallRerun(message, index, $event)"
|
||||
/>
|
||||
<ChatUserItem
|
||||
v-else-if="message.role === 'user'"
|
||||
:message="message"
|
||||
:label="labels.user"
|
||||
:scroll-container="chatHistoryRef"
|
||||
:variant="variant"
|
||||
@copy="emitCopyMessage(message, index)"
|
||||
@delete="emitDeleteMessage(message, index)"
|
||||
/>
|
||||
</ChatHistoryMessageFrame>
|
||||
</template>
|
||||
</Virtualizer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-history-list--mobile :deep(.chat-message-item-container) {
|
||||
--chat-top-fade-transparent-stop: -1px;
|
||||
--chat-top-fade-opaque-stop: 0px;
|
||||
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent var(--chat-top-fade-transparent-stop),
|
||||
black var(--chat-top-fade-opaque-stop)
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent var(--chat-top-fade-transparent-stop),
|
||||
black var(--chat-top-fade-opaque-stop)
|
||||
);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,8 +11,10 @@ import { getChatHistoryItemCopyText } from '../utils'
|
||||
const props = withDefaults(defineProps<{
|
||||
message: Extract<ChatMessage, { role: 'user' }>
|
||||
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))
|
||||
</script>
|
||||
@@ -53,6 +57,7 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
|
||||
<ChatActionMenu
|
||||
:copy-text="copyText"
|
||||
placement="left"
|
||||
:scroll-container="scrollContainer"
|
||||
@copy="emit('copy')"
|
||||
@delete="emit('delete')"
|
||||
>
|
||||
@@ -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 <sm:fit"
|
||||
:class="[
|
||||
'chat-message-item-container',
|
||||
boxClasses,
|
||||
(isStageWeb() || isStageCapacitor()) && props.variant === 'mobile' ? 'select-none sm:select-auto' : '',
|
||||
]"
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import type { EffectScope, ShallowRef } from 'vue'
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, nextTick, shallowRef } from 'vue'
|
||||
|
||||
import { useChatHistoryScroll } from './use-chat-history-scroll'
|
||||
|
||||
interface TestMessage {
|
||||
id: string
|
||||
}
|
||||
|
||||
const activeScopes: EffectScope[] = []
|
||||
|
||||
function createScrollContainer(messageCount: number) {
|
||||
const container = document.createElement('div')
|
||||
container.style.height = '120px'
|
||||
container.style.overflowY = 'auto'
|
||||
container.style.width = '320px'
|
||||
replaceMessageItems(container, messageCount)
|
||||
document.body.appendChild(container)
|
||||
return container
|
||||
}
|
||||
|
||||
function replaceMessageItems(container: HTMLElement, messageCount: number) {
|
||||
const items = Array.from({ length: messageCount }, (_, index) => {
|
||||
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<HTMLElement | null>
|
||||
messages: ShallowRef<TestMessage[]>
|
||||
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<HTMLElement | null>(null)
|
||||
const messages = shallowRef<TestMessage[]>([])
|
||||
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<HTMLElement | null>(currentContainer)
|
||||
const messages = shallowRef<TestMessage[]>([{ 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<HTMLElement | null>(currentContainer)
|
||||
const messages = shallowRef<TestMessage[]>([{ 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<HTMLElement | null>(currentContainer)
|
||||
const messages = shallowRef<TestMessage[]>([{ 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<HTMLElement | null>(currentContainer)
|
||||
const messages = shallowRef<TestMessage[]>([{ 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<HTMLElement | null>(currentContainer)
|
||||
const messages = shallowRef<TestMessage[]>([{ 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')
|
||||
})
|
||||
})
|
||||
-434
@@ -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<ChatHistoryItem[]>(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<ChatHistoryItem[]>([
|
||||
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<ChatHistoryItem[]>([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<ChatHistoryItem[]>([
|
||||
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<ChatHistoryItem[]>([
|
||||
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<ChatHistoryItem[]>([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()
|
||||
})
|
||||
})
|
||||
+109
-387
@@ -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<TMessage> {
|
||||
/**
|
||||
* The scroll container that owns the chat history viewport.
|
||||
*
|
||||
* Use this when the composable should manage scroll state for a specific
|
||||
* `<div>` 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<HTMLDivElement>()
|
||||
*
|
||||
* useChatHistoryScroll({
|
||||
* containerRef: chatHistoryRef,
|
||||
* messages,
|
||||
* getKey,
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
containerRef: Ref<HTMLDivElement | undefined>
|
||||
/**
|
||||
* 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<TMessage[]>
|
||||
/**
|
||||
* 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<ShallowRef<HTMLElement | null>>
|
||||
messages: Readonly<Ref<TMessage[]>>
|
||||
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<TMessage extends { role?: string }>({
|
||||
containerRef,
|
||||
export function useChatHistoryScroll<TMessage>({
|
||||
container,
|
||||
messages,
|
||||
getKey,
|
||||
shouldScroll,
|
||||
scrollToIndex,
|
||||
}: ChatHistoryScrollOptions<TMessage>) {
|
||||
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<string | number | null>(null)
|
||||
const pendingStreamingFollow = shallowRef(false)
|
||||
const previousLastMessageKey = shallowRef<string | number | null>(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<HTMLElement>('[data-chat-message-key]')
|
||||
const messageItem = element?.closest<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('[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 },
|
||||
)
|
||||
}
|
||||
|
||||
+80
@@ -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<ShallowRef<HTMLElement | null>>,
|
||||
selector: string,
|
||||
) {
|
||||
const elements = shallowRef<HTMLElement[]>([])
|
||||
|
||||
const collectElements = () => {
|
||||
const currentRoot = root.value
|
||||
elements.value = currentRoot
|
||||
? Array.from(currentRoot.querySelectorAll<HTMLElement>(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<ShallowRef<HTMLElement | null>>
|
||||
fadeRatio: Readonly<Ref<number>>
|
||||
}) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<ShallowRef<VirtualizerHandle | null>>,
|
||||
) {
|
||||
let didObserveReadyFrame = false
|
||||
const pendingRequest = shallowRef<VirtualScrollRequest>()
|
||||
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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
export const chatScrollContainerKey = Symbol('chat-scroll-container') as InjectionKey<Ref<HTMLDivElement | undefined>>
|
||||
Generated
+37
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user