feat(app): add virtual scrolling for chat messages
Replace v-for message loop with @tanstack/vue-virtual useVirtualizer. Dynamic row measurement, estimated sizes (60px user / 200px assistant), overscan 5 items. Scroll-to-bottom via scrollToIndex during streaming. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
79691c9a25
commit
31f2c2b261
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aiui/core": "workspace:*",
|
||||
"@tanstack/vue-virtual": "^3.13.19",
|
||||
"markdown-it": "^14.1.1",
|
||||
"pinia": "^3.0.4",
|
||||
"plyr": "^3.8.4",
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
@select="handlePromptSelect"
|
||||
/>
|
||||
|
||||
<!-- Expanded: full message list -->
|
||||
<!-- Expanded: full message list (virtualized) -->
|
||||
<div
|
||||
v-else
|
||||
ref="messageListRef"
|
||||
class="relative z-0 flex-1 min-h-0 overflow-y-auto scrollbar-hide p-4 space-y-3"
|
||||
class="relative z-0 flex-1 min-h-0 overflow-y-auto scrollbar-hide"
|
||||
>
|
||||
<div v-if="messages.length === 0" class="flex items-center justify-center h-full">
|
||||
<div v-if="messages.length === 0" class="flex items-center justify-center h-full p-4">
|
||||
<div class="text-center space-y-3 animate-fade-up">
|
||||
<div class="empty-state-icon w-16 h-16 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
|
||||
<span class="text-2xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'">✦</span>
|
||||
@@ -34,19 +34,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary
|
||||
v-for="(msg, i) in messages"
|
||||
:key="msg.id"
|
||||
title="Message failed to render"
|
||||
<div
|
||||
v-else
|
||||
:style="{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }"
|
||||
>
|
||||
<ChatMessage
|
||||
:message="msg"
|
||||
:index="i"
|
||||
:triggering-query="getTriggeringQuery(messages, i)"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<div
|
||||
v-for="virtualRow in virtualizer.getVirtualItems()"
|
||||
:key="messages[virtualRow.index].id"
|
||||
:ref="(el) => el && virtualizer.measureElement(el as Element)"
|
||||
:data-index="virtualRow.index"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}"
|
||||
class="px-4 py-1.5"
|
||||
>
|
||||
<ErrorBoundary title="Message failed to render">
|
||||
<ChatMessage
|
||||
:message="messages[virtualRow.index]"
|
||||
:index="virtualRow.index"
|
||||
:triggering-query="getTriggeringQuery(messages, virtualRow.index)"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StreamingDots v-if="isStreaming && lastMessageEmpty" />
|
||||
<div v-if="isStreaming && lastMessageEmpty" class="px-4 pb-4">
|
||||
<StreamingDots />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
@@ -62,6 +80,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useAI } from '@/composables/useAI'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
@@ -101,6 +120,16 @@ const codeContext = useCodeContext()
|
||||
const messageListRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const messages = computed(() => chatStore.messages)
|
||||
|
||||
const virtualizer = useVirtualizer(computed(() => ({
|
||||
count: messages.value.length,
|
||||
getScrollElement: () => messageListRef.value,
|
||||
estimateSize: (index: number) => {
|
||||
// User messages are typically shorter
|
||||
return messages.value[index]?.role === 'user' ? 60 : 200
|
||||
},
|
||||
overscan: 5,
|
||||
})))
|
||||
const isStreaming = computed(() => chatStore.isStreaming)
|
||||
const chatCollapsed = computed(() => chatStore.chatCollapsed)
|
||||
|
||||
@@ -216,14 +245,22 @@ function handlePromptSelect(_userMsg: Message, assistantMsg: Message | null) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => messages.value.length,
|
||||
() => {
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messages.value.length > 0) {
|
||||
virtualizer.value.scrollToIndex(messages.value.length - 1, { align: 'end' })
|
||||
}
|
||||
// Fallback: also scroll the container directly for streaming updates
|
||||
nextTick(() => {
|
||||
const el = messageListRef.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => messages.value.length,
|
||||
() => scrollToBottom()
|
||||
)
|
||||
|
||||
watch(
|
||||
@@ -233,10 +270,7 @@ watch(
|
||||
return last ? { content: last.content, webResults: last.webResults } : null
|
||||
},
|
||||
(val) => {
|
||||
nextTick(() => {
|
||||
const el = messageListRef.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
scrollToBottom()
|
||||
if (val?.content) {
|
||||
const msgs = messages.value
|
||||
const lastMsg = msgs[msgs.length - 1]
|
||||
|
||||
Generated
+18
@@ -20,6 +20,9 @@ importers:
|
||||
'@aiui/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@tanstack/vue-virtual':
|
||||
specifier: ^3.13.19
|
||||
version: 3.13.19(vue@3.5.29(typescript@5.8.3))
|
||||
markdown-it:
|
||||
specifier: ^14.1.1
|
||||
version: 14.1.1
|
||||
@@ -1159,6 +1162,14 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tanstack/virtual-core@3.13.19':
|
||||
resolution: {integrity: sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.19':
|
||||
resolution: {integrity: sha512-07Fp1TYuIziB4zIDA/moeDKHODePy3K1fN4c4VIAGnkxo1+uOvBJP7m54CoxKiQX6Q9a1dZnznrwOg9C86yvvA==}
|
||||
peerDependencies:
|
||||
vue: ^2.7.0 || ^3.0.0
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
@@ -4047,6 +4058,13 @@ snapshots:
|
||||
tailwindcss: 4.2.1
|
||||
vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@tanstack/virtual-core@3.13.19': {}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.19(vue@3.5.29(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.19
|
||||
vue: 3.5.29(typescript@5.8.3)
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
|
||||
Reference in New Issue
Block a user