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
+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]