Add virtualized chat pagination (#1080)
This commit is contained in:
@@ -11,6 +11,8 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const appStore = useAppStore()
|
||||
const profilesStore = useProfilesStore()
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
|
||||
@@ -150,6 +152,9 @@ function selectBridgeCommand(command: { name: string; args: string; insertText?:
|
||||
|
||||
const contextLength = ref(256000)
|
||||
const FALLBACK_CONTEXT = 256000
|
||||
let contextLengthLoadedKey = ''
|
||||
let contextLengthRequestKey = ''
|
||||
let contextLengthRequest: Promise<void> | null = null
|
||||
|
||||
// Context length editing
|
||||
const showContextEditModal = ref(false)
|
||||
@@ -169,8 +174,8 @@ async function saveContextLimit() {
|
||||
|
||||
isSavingContextLimit.value = true
|
||||
try {
|
||||
const provider = chatStore.activeSession?.provider || useAppStore().selectedProvider || ''
|
||||
const model = chatStore.activeSession?.model || useAppStore().selectedModel || ''
|
||||
const provider = chatStore.activeSession?.provider || appStore.selectedProvider || ''
|
||||
const model = chatStore.activeSession?.model || appStore.selectedModel || ''
|
||||
|
||||
if (!provider || !model) {
|
||||
message.error(t('chat.contextEditFailed'))
|
||||
@@ -179,6 +184,7 @@ async function saveContextLimit() {
|
||||
|
||||
await setModelContext(provider, model, editingContextLimit.value)
|
||||
contextLength.value = editingContextLimit.value
|
||||
contextLengthLoadedKey = currentContextLengthKey()
|
||||
showContextEditModal.value = false
|
||||
message.success(t('chat.contextEditSuccess'))
|
||||
} catch (err: any) {
|
||||
@@ -188,28 +194,61 @@ async function saveContextLimit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContextLength() {
|
||||
try {
|
||||
const activeSession = chatStore.activeSession
|
||||
const profile = activeSession?.profile || useProfilesStore().activeProfileName || undefined
|
||||
contextLength.value = await fetchContextLength(
|
||||
profile,
|
||||
activeSession?.provider || undefined,
|
||||
activeSession?.model || undefined,
|
||||
)
|
||||
} catch {
|
||||
contextLength.value = FALLBACK_CONTEXT
|
||||
function currentContextLengthParams() {
|
||||
const activeSession = chatStore.activeSession
|
||||
return {
|
||||
profile: activeSession?.profile || profilesStore.activeProfileName || undefined,
|
||||
provider: activeSession?.provider || undefined,
|
||||
model: activeSession?.model || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function currentContextLengthKey() {
|
||||
const params = currentContextLengthParams()
|
||||
return `${params.profile || ''}|${params.provider || ''}|${params.model || ''}`
|
||||
}
|
||||
|
||||
async function loadContextLength() {
|
||||
const key = currentContextLengthKey()
|
||||
if (key === contextLengthLoadedKey) return
|
||||
if (key === contextLengthRequestKey && contextLengthRequest) return contextLengthRequest
|
||||
|
||||
contextLengthRequestKey = key
|
||||
contextLengthRequest = (async () => {
|
||||
const params = currentContextLengthParams()
|
||||
try {
|
||||
const value = await fetchContextLength(params.profile, params.provider, params.model)
|
||||
if (currentContextLengthKey() !== key) return
|
||||
contextLength.value = value
|
||||
contextLengthLoadedKey = key
|
||||
} catch {
|
||||
if (currentContextLengthKey() !== key) return
|
||||
contextLength.value = FALLBACK_CONTEXT
|
||||
contextLengthLoadedKey = key
|
||||
} finally {
|
||||
if (contextLengthRequestKey === key) {
|
||||
contextLengthRequest = null
|
||||
contextLengthRequestKey = ''
|
||||
}
|
||||
}
|
||||
})()
|
||||
return contextLengthRequest
|
||||
}
|
||||
|
||||
onMounted(loadContextLength)
|
||||
watch(() => useProfilesStore().activeProfileName, loadContextLength)
|
||||
watch(() => useAppStore().selectedProvider, loadContextLength)
|
||||
watch(() => useAppStore().selectedModel, loadContextLength)
|
||||
watch(() => chatStore.activeSession?.id, loadContextLength)
|
||||
watch(() => chatStore.activeSession?.profile, loadContextLength)
|
||||
watch(() => chatStore.activeSession?.provider, loadContextLength)
|
||||
watch(() => chatStore.activeSession?.model, loadContextLength)
|
||||
watch(
|
||||
() => [
|
||||
profilesStore.activeProfileName,
|
||||
appStore.selectedProvider,
|
||||
appStore.selectedModel,
|
||||
chatStore.activeSession?.id,
|
||||
chatStore.activeSession?.profile,
|
||||
chatStore.activeSession?.provider,
|
||||
chatStore.activeSession?.model,
|
||||
],
|
||||
loadContextLength,
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
const totalTokens = computed(() => {
|
||||
const context = chatStore.activeSession?.contextTokens
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
import { ref, computed, 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 { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
|
||||
@@ -13,7 +14,7 @@ const props = defineProps<{
|
||||
const chatStore = useChatStore();
|
||||
const { toolTraceVisible } = useToolTraceVisibility();
|
||||
const { t } = useI18n();
|
||||
const listRef = ref<HTMLElement>();
|
||||
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
|
||||
|
||||
// Use provided session or fall back to chatStore's active session
|
||||
const activeSession = computed(() => props.session || chatStore.activeSession);
|
||||
@@ -29,38 +30,27 @@ const displayMessages = computed(() =>
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Scroll to bottom on session switch
|
||||
watch(
|
||||
() => chatStore.activeSessionId,
|
||||
() => activeSession.value?.id,
|
||||
(id) => {
|
||||
if (!id) return;
|
||||
if (chatStore.focusMessageId) {
|
||||
nextTick(() => scrollToMessage(chatStore.focusMessageId!));
|
||||
scrollToMessage(chatStore.focusMessageId);
|
||||
return;
|
||||
}
|
||||
nextTick(() => scrollToBottom());
|
||||
scrollToBottom();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -94,37 +84,28 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="listRef" class="message-list">
|
||||
<div v-if="!activeSession || activeSession.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"
|
||||
/>
|
||||
</div>
|
||||
<VirtualMessageList
|
||||
ref="listRef"
|
||||
:messages="displayMessages"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<img src="/logo.png" alt="Hermes" class="empty-logo" />
|
||||
<p>{{ t("chat.emptyState") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #item="{ message: msg }">
|
||||
<MessageItem
|
||||
:message="msg"
|
||||
:highlight="chatStore.focusMessageId === msg.id"
|
||||
/>
|
||||
</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;
|
||||
|
||||
.dark & {
|
||||
background-color: #333333;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance } from "vue";
|
||||
|
||||
type VirtualItem = {
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
messages: VirtualItem[];
|
||||
estimatedItemHeight?: number;
|
||||
overscan?: number;
|
||||
rowGap?: number;
|
||||
padding?: string;
|
||||
topThreshold?: number;
|
||||
}>(), {
|
||||
estimatedItemHeight: 180,
|
||||
overscan: 8,
|
||||
rowGap: 16,
|
||||
padding: "20px",
|
||||
topThreshold: 120,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
scroll: [];
|
||||
topReach: [];
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
empty?: () => any;
|
||||
before?: () => any;
|
||||
item?: (props: { message: any }) => any;
|
||||
after?: () => any;
|
||||
}>();
|
||||
|
||||
const scrollerRef = ref<HTMLElement | null>(null);
|
||||
const scrollTop = ref(0);
|
||||
const viewportHeight = ref(0);
|
||||
const heightVersion = ref(0);
|
||||
const measuredHeights = new Map<string, number>();
|
||||
const observedElements = new Map<string, HTMLElement>();
|
||||
const observers = new Map<string, ResizeObserver>();
|
||||
|
||||
const messageKeys = computed(() => props.messages.map(messageKey));
|
||||
|
||||
function messageKey(message: VirtualItem): string {
|
||||
return String(message.id);
|
||||
}
|
||||
|
||||
function itemHeight(key: string): number {
|
||||
return measuredHeights.get(key) || props.estimatedItemHeight;
|
||||
}
|
||||
|
||||
const layout = computed(() => {
|
||||
heightVersion.value;
|
||||
const offsets: number[] = [];
|
||||
let total = 0;
|
||||
for (const key of messageKeys.value) {
|
||||
offsets.push(total);
|
||||
total += itemHeight(key);
|
||||
}
|
||||
return { offsets, total };
|
||||
});
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
const count = props.messages.length;
|
||||
if (count === 0) return { start: 0, end: -1 };
|
||||
|
||||
const overscanPx = props.estimatedItemHeight * props.overscan;
|
||||
const startPx = Math.max(0, scrollTop.value - overscanPx);
|
||||
const endPx = scrollTop.value + viewportHeight.value + overscanPx;
|
||||
const { offsets } = layout.value;
|
||||
|
||||
let start = 0;
|
||||
let low = 0;
|
||||
let high = count - 1;
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const bottom = offsets[mid] + itemHeight(messageKeys.value[mid]);
|
||||
if (bottom < startPx) low = mid + 1;
|
||||
else {
|
||||
start = mid;
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
let end = start;
|
||||
while (end < count - 1 && offsets[end] < endPx) end += 1;
|
||||
return { start, end };
|
||||
});
|
||||
|
||||
const visibleMessages = computed(() => {
|
||||
const { start, end } = visibleRange.value;
|
||||
return end >= start ? props.messages.slice(start, end + 1) : [];
|
||||
});
|
||||
|
||||
const topSpacerHeight = computed(() => layout.value.offsets[visibleRange.value.start] || 0);
|
||||
const bottomSpacerHeight = computed(() => {
|
||||
const { end } = visibleRange.value;
|
||||
if (end < 0) return 0;
|
||||
const nextOffset = end + 1 < props.messages.length
|
||||
? layout.value.offsets[end + 1]
|
||||
: layout.value.total;
|
||||
return Math.max(0, layout.value.total - nextOffset);
|
||||
});
|
||||
|
||||
function setItemRef(key: string, el: Element | ComponentPublicInstance | null) {
|
||||
const existing = observedElements.get(key);
|
||||
if (existing === el) return;
|
||||
|
||||
observers.get(key)?.disconnect();
|
||||
observers.delete(key);
|
||||
observedElements.delete(key);
|
||||
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
|
||||
observedElements.set(key, el);
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
const height = Math.ceil(el.getBoundingClientRect().height || props.estimatedItemHeight);
|
||||
measuredHeights.set(key, height);
|
||||
heightVersion.value += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const height = Math.ceil(entries[0]?.contentRect.height || props.estimatedItemHeight);
|
||||
if (measuredHeights.get(key) === height) return;
|
||||
measuredHeights.set(key, height);
|
||||
heightVersion.value += 1;
|
||||
});
|
||||
observer.observe(el);
|
||||
observers.set(key, observer);
|
||||
}
|
||||
|
||||
function syncViewport() {
|
||||
const el = scrollerRef.value;
|
||||
if (!el) return;
|
||||
scrollTop.value = el.scrollTop;
|
||||
viewportHeight.value = el.clientHeight;
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
syncViewport();
|
||||
emit("scroll");
|
||||
if (scrollTop.value <= props.topThreshold) emit("topReach");
|
||||
}
|
||||
|
||||
function isNearBottom(threshold = 200): boolean {
|
||||
const el = scrollerRef.value;
|
||||
if (!el) return true;
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
const el = scrollerRef.value;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
syncViewport();
|
||||
});
|
||||
}
|
||||
|
||||
function scrollToMessage(messageId: string) {
|
||||
const index = props.messages.findIndex(message => String(message.id) === messageId);
|
||||
if (index < 0) return;
|
||||
|
||||
nextTick(() => {
|
||||
const el = scrollerRef.value;
|
||||
if (!el) return;
|
||||
el.scrollTop = Math.max(0, (layout.value.offsets[index] || 0) - el.clientHeight / 2);
|
||||
syncViewport();
|
||||
nextTick(() => {
|
||||
document.getElementById(`message-${messageId}`)?.scrollIntoView({ block: "center" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function captureScrollPosition() {
|
||||
const el = scrollerRef.value;
|
||||
if (!el) return null;
|
||||
return {
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreScrollPosition(snapshot: { scrollTop: number; scrollHeight: number } | null) {
|
||||
if (!snapshot) return;
|
||||
nextTick(() => {
|
||||
const el = scrollerRef.value;
|
||||
if (!el) return;
|
||||
el.scrollTop = Math.max(0, el.scrollHeight - snapshot.scrollHeight + snapshot.scrollTop);
|
||||
syncViewport();
|
||||
});
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
syncViewport();
|
||||
if (scrollerRef.value && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(syncViewport);
|
||||
resizeObserver.observe(scrollerRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect();
|
||||
for (const observer of observers.values()) observer.disconnect();
|
||||
observers.clear();
|
||||
observedElements.clear();
|
||||
});
|
||||
|
||||
watch(messageKeys, keys => {
|
||||
const activeKeys = new Set(keys);
|
||||
for (const key of [...observedElements.keys()]) {
|
||||
if (activeKeys.has(key)) continue;
|
||||
observers.get(key)?.disconnect();
|
||||
observers.delete(key);
|
||||
observedElements.delete(key);
|
||||
}
|
||||
nextTick(syncViewport);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
isNearBottom,
|
||||
scrollToBottom,
|
||||
scrollToMessage,
|
||||
captureScrollPosition,
|
||||
restoreScrollPosition,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="scrollerRef"
|
||||
class="virtual-message-list"
|
||||
:style="{ '--virtual-row-gap': `${rowGap}px`, '--virtual-list-padding': padding }"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<slot v-if="messages.length === 0" name="empty" />
|
||||
<template v-else>
|
||||
<slot name="before" />
|
||||
<div class="virtual-spacer" :style="{ height: `${topSpacerHeight}px` }" />
|
||||
<div
|
||||
v-for="msg in visibleMessages"
|
||||
:key="msg.id"
|
||||
:ref="(el) => setItemRef(messageKey(msg), el)"
|
||||
class="virtual-row"
|
||||
>
|
||||
<slot name="item" :message="msg" />
|
||||
</div>
|
||||
<div class="virtual-spacer" :style="{ height: `${bottomSpacerHeight}px` }" />
|
||||
</template>
|
||||
<slot name="after" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.virtual-message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--virtual-list-padding);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: $bg-card;
|
||||
position: relative;
|
||||
|
||||
.dark & {
|
||||
background-color: #333333;
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-spacer {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.virtual-row {
|
||||
padding-bottom: var(--virtual-row-gap);
|
||||
}
|
||||
</style>
|
||||
@@ -4,27 +4,30 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||
import GroupMessageItem from './GroupMessageItem.vue'
|
||||
import VirtualMessageList from '../chat/VirtualMessageList.vue'
|
||||
|
||||
const store = useGroupChatStore()
|
||||
const { t } = useI18n()
|
||||
const { toolTraceVisible } = useToolTraceVisibility()
|
||||
const listRef = ref<HTMLDivElement>()
|
||||
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null)
|
||||
const isNearBottom = ref(true)
|
||||
const displayMessages = computed(() => store.sortedMessages.filter(msg => msg.role !== 'tool' || toolTraceVisible.value || msg.toolStatus === 'running'))
|
||||
|
||||
function checkNearBottom(): void {
|
||||
if (!listRef.value) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.value
|
||||
isNearBottom.value = scrollHeight - scrollTop - clientHeight < 200
|
||||
isNearBottom.value = listRef.value?.isNearBottom(200) ?? true
|
||||
}
|
||||
|
||||
function scrollToBottom(): void {
|
||||
if (!listRef.value) return
|
||||
listRef.value.scrollTop = listRef.value.scrollHeight
|
||||
listRef.value?.scrollToBottom()
|
||||
}
|
||||
|
||||
function handleScroll(): void {
|
||||
checkNearBottom()
|
||||
async function handleTopReach(): Promise<void> {
|
||||
if (!store.hasMoreBefore || store.isLoadingOlderMessages) return
|
||||
const snapshot = listRef.value?.captureScrollPosition() ?? null
|
||||
const loaded = await store.loadOlderMessages()
|
||||
if (!loaded) return
|
||||
await nextTick()
|
||||
listRef.value?.restoreScrollPosition(snapshot)
|
||||
}
|
||||
|
||||
watch(() => store.messages.length, async () => {
|
||||
@@ -38,39 +41,42 @@ defineExpose({ scrollToBottom })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="listRef" class="message-list" @scroll="handleScroll">
|
||||
<div v-if="displayMessages.length === 0" class="empty-state">
|
||||
<VirtualMessageList
|
||||
ref="listRef"
|
||||
:messages="displayMessages"
|
||||
:estimated-item-height="170"
|
||||
:row-gap="12"
|
||||
padding="16px 20px"
|
||||
@scroll="checkNearBottom"
|
||||
@top-reach="handleTopReach"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<img src="/logo.png" alt="Hermes" class="empty-logo" />
|
||||
<p>{{ t("chat.emptyState") }}</p>
|
||||
</div>
|
||||
<GroupMessageItem
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:agents="store.agents"
|
||||
:current-user-id="store.userId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #before>
|
||||
<div
|
||||
v-if="store.hasMoreBefore || store.isLoadingOlderMessages"
|
||||
class="history-loader"
|
||||
>
|
||||
<span v-if="store.isLoadingOlderMessages" class="history-loader-spinner"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #item="{ message: msg }">
|
||||
<GroupMessageItem
|
||||
:message="msg"
|
||||
:agents="store.agents"
|
||||
:current-user-id="store.userId"
|
||||
/>
|
||||
</template>
|
||||
</VirtualMessageList>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background-color: $bg-card;
|
||||
position: relative;
|
||||
|
||||
.dark & {
|
||||
background-color: #333333;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -90,4 +96,32 @@ defineExpose({ scrollToBottom })
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user