diff --git a/packages/client/src/components/hermes/chat/MessageList.vue b/packages/client/src/components/hermes/chat/MessageList.vue index 27eb22d..d845dab 100644 --- a/packages/client/src/components/hermes/chat/MessageList.vue +++ b/packages/client/src/components/hermes/chat/MessageList.vue @@ -14,6 +14,7 @@ const { t } = useI18n(); const { isDark } = useTheme(); const { toolTraceVisible } = useToolTraceVisibility(); const listRef = ref | null>(null); +const pendingBottomSessionId = ref(null); function formatTokens(n: number): string { if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' @@ -115,6 +116,7 @@ watch( () => chatStore.activeSessionId, (id) => { if (!id) return; + pendingBottomSessionId.value = id; if (chatStore.focusMessageId) { scrollToMessage(chatStore.focusMessageId); return; @@ -124,6 +126,20 @@ watch( { immediate: true }, ); +watch( + () => [chatStore.activeSessionId, chatStore.messages.length] as const, + ([id, length]) => { + if (!id || pendingBottomSessionId.value !== id || length === 0) return; + pendingBottomSessionId.value = null; + if (chatStore.focusMessageId) { + scrollToMessage(chatStore.focusMessageId); + return; + } + scrollToBottom(); + }, + { flush: "post" }, +); + watch( () => chatStore.focusMessageId, (messageId) => { diff --git a/packages/client/src/components/hermes/chat/VirtualMessageList.vue b/packages/client/src/components/hermes/chat/VirtualMessageList.vue index 4db1361..a448dea 100644 --- a/packages/client/src/components/hermes/chat/VirtualMessageList.vue +++ b/packages/client/src/components/hermes/chat/VirtualMessageList.vue @@ -39,6 +39,8 @@ const heightVersion = ref(0); const measuredHeights = new Map(); const observedElements = new Map(); const observers = new Map(); +let keepBottomUntil = 0; +let bottomFrame: number | null = null; const messageKeys = computed(() => props.messages.map(messageKey)); @@ -58,9 +60,10 @@ function setMeasuredHeight(key: string, height: number) { const oldHeight = itemHeight(key); if (oldHeight === height) return; + const el = scrollerRef.value; + const shouldKeepBottom = !!el && (Date.now() < keepBottomUntil || isNearBottom(64)); const index = messageKeys.value.indexOf(key); - if (index >= 0) { - const el = scrollerRef.value; + if (index >= 0 && !shouldKeepBottom) { const rowTop = layout.value.offsets[index] || 0; const delta = height - oldHeight; if (el && rowTop < scrollTop.value && delta !== 0) { @@ -71,6 +74,7 @@ function setMeasuredHeight(key: string, height: number) { measuredHeights.set(key, height); heightVersion.value += 1; + if (shouldKeepBottom) scheduleScrollToBottom(2); } const layout = computed(() => { @@ -167,14 +171,34 @@ function isNearBottom(threshold = 200): boolean { } function scrollToBottom() { + keepBottomUntil = Date.now() + 700; nextTick(() => { - const el = scrollerRef.value; - if (!el) return; - el.scrollTop = el.scrollHeight; - syncViewport(); + scheduleScrollToBottom(3); }); } +function setScrollToBottomNow() { + const el = scrollerRef.value; + if (!el) return; + el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight); + syncViewport(); +} + +function scheduleScrollToBottom(frames = 1) { + if (bottomFrame != null) cancelAnimationFrame(bottomFrame); + + const step = (remaining: number) => { + setScrollToBottomNow(); + if (remaining <= 1) { + bottomFrame = null; + return; + } + bottomFrame = requestAnimationFrame(() => step(remaining - 1)); + }; + + bottomFrame = requestAnimationFrame(() => step(frames)); +} + function scrollToMessage(messageId: string) { const index = props.messages.findIndex(message => String(message.id) === messageId); if (index < 0) return; @@ -237,6 +261,7 @@ onMounted(() => { }); onBeforeUnmount(() => { + if (bottomFrame != null) cancelAnimationFrame(bottomFrame); resizeObserver?.disconnect(); for (const observer of observers.values()) observer.disconnect(); observers.clear();