Add virtualized chat pagination (#1080)

This commit is contained in:
ekko
2026-05-28 09:34:30 +08:00
committed by GitHub
parent 21bb8385f2
commit a6b3bec29b
16 changed files with 692 additions and 161 deletions
@@ -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;