perf(stage-ui): virtualize chat history (#2334)

This commit is contained in:
Neko
2026-08-21 05:32:21 +08:00
committed by GitHub
parent a24d2496e4
commit b6d0809ecb
17 changed files with 753 additions and 1009 deletions
@@ -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>
+1
View File
@@ -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>
@@ -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' : '',
]"
@@ -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')
})
})
@@ -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()
})
})
@@ -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 },
)
}
@@ -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>>
+37
View File
@@ -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
+1
View File
@@ -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