Add virtualized chat pagination (#1080)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
import { ref, computed, nextTick, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import VirtualMessageList from "./VirtualMessageList.vue";
|
||||
import MessageItem from "./MessageItem.vue";
|
||||
import { useChatStore } from "@/stores/hermes/chat";
|
||||
import thinkingImageLight from "@/assets/thinking-light.gif";
|
||||
@@ -12,7 +13,7 @@ const chatStore = useChatStore();
|
||||
const { t } = useI18n();
|
||||
const { isDark } = useTheme();
|
||||
const { toolTraceVisible } = useToolTraceVisibility();
|
||||
const listRef = ref<HTMLElement>();
|
||||
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||
@@ -84,26 +85,25 @@ function queuedPreview(content: string): string {
|
||||
}
|
||||
|
||||
function isNearBottom(threshold = 200): boolean {
|
||||
const el = listRef.value;
|
||||
if (!el) return true;
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
||||
return listRef.value?.isNearBottom(threshold) ?? true;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (listRef.value) {
|
||||
listRef.value.scrollTop = listRef.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
listRef.value?.scrollToBottom();
|
||||
}
|
||||
|
||||
function scrollToMessage(messageId: string) {
|
||||
nextTick(() => {
|
||||
const el = document.getElementById(`message-${messageId}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
});
|
||||
listRef.value?.scrollToMessage(messageId);
|
||||
}
|
||||
|
||||
async function handleTopReach() {
|
||||
const session = chatStore.activeSession;
|
||||
if (!session?.hasMoreBefore || session.isLoadingOlderMessages) return;
|
||||
const snapshot = listRef.value?.captureScrollPosition() ?? null;
|
||||
const loaded = await chatStore.loadOlderMessages(session.id);
|
||||
if (!loaded) return;
|
||||
await nextTick();
|
||||
listRef.value?.restoreScrollPosition(snapshot);
|
||||
}
|
||||
|
||||
// Scroll to bottom on session switch
|
||||
@@ -112,10 +112,10 @@ watch(
|
||||
(id) => {
|
||||
if (!id) return;
|
||||
if (chatStore.focusMessageId) {
|
||||
nextTick(() => scrollToMessage(chatStore.focusMessageId!));
|
||||
scrollToMessage(chatStore.focusMessageId);
|
||||
return;
|
||||
}
|
||||
nextTick(() => scrollToBottom());
|
||||
scrollToBottom();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -159,18 +159,33 @@ watch(currentToolCalls, () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="listRef" class="message-list">
|
||||
<div v-if="chatStore.messages.length === 0" class="empty-state">
|
||||
<img src="/logo.png" alt="Hermes" class="empty-logo" />
|
||||
<p>{{ t("chat.emptyState") }}</p>
|
||||
</div>
|
||||
<MessageItem
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:highlight="chatStore.focusMessageId === msg.id"
|
||||
/>
|
||||
<Transition name="fade">
|
||||
<VirtualMessageList
|
||||
ref="listRef"
|
||||
:messages="displayMessages"
|
||||
@top-reach="handleTopReach"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<img src="/logo.png" alt="Hermes" class="empty-logo" />
|
||||
<p>{{ t("chat.emptyState") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #before>
|
||||
<div
|
||||
v-if="chatStore.activeSession?.hasMoreBefore || chatStore.activeSession?.isLoadingOlderMessages"
|
||||
class="history-loader"
|
||||
>
|
||||
<span v-if="chatStore.activeSession?.isLoadingOlderMessages" class="history-loader-spinner"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #item="{ message: msg }">
|
||||
<MessageItem
|
||||
:message="msg"
|
||||
:highlight="chatStore.focusMessageId === msg.id"
|
||||
/>
|
||||
</template>
|
||||
<template #after>
|
||||
<Transition name="fade">
|
||||
<div v-if="chatStore.isRunActive || chatStore.abortState" class="streaming-indicator">
|
||||
<img
|
||||
:src="isDark ? thinkingImageDark : thinkingImageLight"
|
||||
@@ -331,8 +346,8 @@ watch(currentToolCalls, () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="queue-float">
|
||||
</Transition>
|
||||
<Transition name="queue-float">
|
||||
<div v-if="queuedMessages.length > 0" class="queue-float-panel">
|
||||
<div class="queue-float-header">
|
||||
<span class="queue-orbit" aria-hidden="true">
|
||||
@@ -363,36 +378,22 @@ watch(currentToolCalls, () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
</VirtualMessageList>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background-color: $bg-card;
|
||||
position: relative;
|
||||
|
||||
.dark & {
|
||||
background-color: #333333;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-float-panel {
|
||||
position: sticky;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 4;
|
||||
align-self: flex-end;
|
||||
width: min(340px, calc(100% - 16px));
|
||||
margin-top: auto;
|
||||
margin-top: 16px;
|
||||
margin-left: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(var(--accent-info-rgb), 0.22);
|
||||
border-radius: 16px;
|
||||
@@ -606,6 +607,28 @@ watch(currentToolCalls, () => {
|
||||
}
|
||||
}
|
||||
|
||||
.history-loader {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.history-loader-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.16);
|
||||
border-top-color: $accent-primary;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
|
||||
.dark & {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
border-top-color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.4s ease;
|
||||
|
||||
Reference in New Issue
Block a user