diff --git a/packages/app/package.json b/packages/app/package.json index a7da454a..94cf7fb5 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/components/chat/ChatWindow.vue b/packages/app/src/components/chat/ChatWindow.vue index a4167635..599b6332 100644 --- a/packages/app/src/components/chat/ChatWindow.vue +++ b/packages/app/src/components/chat/ChatWindow.vue @@ -17,13 +17,13 @@ @select="handlePromptSelect" /> - +
-
+
@@ -34,19 +34,37 @@
- - - +
+ + + +
+
- +
+ +
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(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] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33dfadb4..14e55265 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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