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:
Dorian
2026-03-03 20:24:44 +00:00
co-authored by Claude Opus 4.6
parent 79691c9a25
commit 31f2c2b261
3 changed files with 75 additions and 22 deletions
+1
View File
@@ -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",
+56 -22
View File
@@ -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]
+18
View File
@@ -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