From 818e7f751fcdc854f08d3a83719ae2e1d19b4337 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Sat, 30 May 2026 11:46:32 +0800 Subject: [PATCH] fix chat scroll jitter (#1146) --- .../hermes/chat/HistoryMessageList.vue | 8 ++- .../hermes/chat/MarkdownRenderer.vue | 16 +++--- .../hermes/chat/VirtualMessageList.vue | 49 ++++++++++++++----- .../hermes/group-chat/GroupChatPanel.vue | 10 +--- .../hermes/group-chat/GroupMessageList.vue | 45 +++++++++++++---- 5 files changed, 92 insertions(+), 36 deletions(-) diff --git a/packages/client/src/components/hermes/chat/HistoryMessageList.vue b/packages/client/src/components/hermes/chat/HistoryMessageList.vue index 69a09d3..60ed818 100644 --- a/packages/client/src/components/hermes/chat/HistoryMessageList.vue +++ b/packages/client/src/components/hermes/chat/HistoryMessageList.vue @@ -16,6 +16,7 @@ const chatStore = useChatStore(); const { toolTraceVisible } = useToolTraceVisibility(); const { t } = useI18n(); const listRef = ref | null>(null); +const pendingBottomSessionId = ref(null); // Use provided session or fall back to chatStore's active session const activeSession = computed(() => props.session || chatStore.activeSession); @@ -61,6 +62,7 @@ watch( () => activeSession.value?.id, (id) => { if (!id) return; + pendingBottomSessionId.value = id; if (chatStore.focusMessageId) { scrollToMessage(chatStore.focusMessageId); return; @@ -92,9 +94,13 @@ watch( () => (activeSession.value?.messages || []).length, (length) => { if (length === 0) return - if (!isNearBottom()) return; + const id = activeSession.value?.id + const shouldForceBottom = !!id && pendingBottomSessionId.value === id + if (!shouldForceBottom && !isNearBottom()) return; + if (shouldForceBottom) pendingBottomSessionId.value = null scrollToBottom(); }, + { flush: "post" }, ); defineExpose({ diff --git a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue index 0c235db..a487426 100644 --- a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue +++ b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue @@ -241,6 +241,10 @@ function getScrollParent(el: HTMLElement | null): HTMLElement | null { return null } +function isNearScrollBottom(el: HTMLElement, threshold = 200): boolean { + return el.scrollHeight - el.scrollTop - el.clientHeight < threshold +} + function cleanupMermaidRenderArtifacts(id: string): void { document.getElementById(id)?.remove() document.getElementById(`d${id}`)?.remove() @@ -312,16 +316,16 @@ async function renderMermaidDiagrams(): Promise { cleanupMermaidRenderArtifacts(id) if (unmounted || generation !== renderGeneration || !root.contains(element)) return + const scrollParent = getScrollParent(markdownBody.value) + const shouldKeepBottom = scrollParent ? isNearScrollBottom(scrollParent) : false element.removeAttribute('data-mermaid-pending') element.removeAttribute('data-mermaid-source') element.innerHTML = result.svg - // After mermaid renders, scroll the nearest scrollable ancestor to bottom - nextTick(() => { - const scrollParent = getScrollParent(markdownBody.value) - if (scrollParent) { + if (scrollParent && shouldKeepBottom) { + nextTick(() => { scrollParent.scrollTop = scrollParent.scrollHeight - } - }) + }) + } } catch { cleanupMermaidRenderArtifacts(`${componentId}-${generation}-${index}`) if (unmounted || generation !== renderGeneration || !root.contains(element)) return diff --git a/packages/client/src/components/hermes/chat/VirtualMessageList.vue b/packages/client/src/components/hermes/chat/VirtualMessageList.vue index 4645b10..f7dc00f 100644 --- a/packages/client/src/components/hermes/chat/VirtualMessageList.vue +++ b/packages/client/src/components/hermes/chat/VirtualMessageList.vue @@ -20,6 +20,10 @@ type AnchorTarget = { anchorId: string; align: AnchorAlign; } +type BottomScrollOptions = number | { + frames?: number; + keepAliveMs?: number; +} const props = withDefaults(defineProps<{ messages: VirtualItem[]; @@ -54,6 +58,8 @@ const scrollTop = ref(0); const viewportHeight = ref(0); let keepBottomUntil = 0; let bottomFrame: number | null = null; +let bottomFrameRemaining = 0; +let bottomFrameAttempts = 0; let anchorFrame: number | null = null; let anchorToken = 0; let activeAnchorTarget: AnchorTarget | null = null; @@ -94,35 +100,54 @@ function isNearBottom(threshold = 200): boolean { return el.scrollHeight - el.scrollTop - el.clientHeight < threshold; } -function scrollToBottom() { - keepBottomUntil = Date.now() + 700; +function scrollToBottom(options: BottomScrollOptions = {}) { + const frames = typeof options === "number" ? options : options.frames ?? 5; + const keepAliveMs = typeof options === "number" ? 700 : options.keepAliveMs ?? 700; + keepBottomUntil = Date.now() + keepAliveMs; nextTick(() => { - scheduleScrollToBottom(3); + scheduleScrollToBottom(frames); }); } -function setScrollToBottomNow() { +function setScrollToBottomNow(): boolean { const el = getScrollerElement(); scrollerRef.value?.scrollToBottom(); if (el) { el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight); + syncViewport(); + return true; } - syncViewport(); + return false; } function scheduleScrollToBottom(frames = 1) { - if (bottomFrame != null) cancelAnimationFrame(bottomFrame); + bottomFrameRemaining = Math.max(bottomFrameRemaining, frames); + if (bottomFrame != null) return; - const step = (remaining: number) => { - setScrollToBottomNow(); - if (remaining <= 1) { + const step = () => { + const scrolled = setScrollToBottomNow(); + if (scrolled) { + bottomFrameAttempts = 0; + bottomFrameRemaining -= 1; + } else { + bottomFrameAttempts += 1; + } + if (bottomFrameRemaining <= 0) { bottomFrame = null; + bottomFrameRemaining = 0; + bottomFrameAttempts = 0; return; } - bottomFrame = requestAnimationFrame(() => step(remaining - 1)); + if (bottomFrameAttempts > 30) { + bottomFrame = null; + bottomFrameRemaining = 0; + bottomFrameAttempts = 0; + return; + } + bottomFrame = requestAnimationFrame(step); }; - bottomFrame = requestAnimationFrame(() => step(frames)); + bottomFrame = requestAnimationFrame(step); } function findTargetElement(messageId: string, anchorId: string): HTMLElement | null { @@ -275,6 +300,8 @@ onMounted(() => { onBeforeUnmount(() => { if (bottomFrame != null) cancelAnimationFrame(bottomFrame); + bottomFrameRemaining = 0; + bottomFrameAttempts = 0; if (anchorFrame != null) cancelAnimationFrame(anchorFrame); resizeObserver?.disconnect(); }); diff --git a/packages/client/src/components/hermes/group-chat/GroupChatPanel.vue b/packages/client/src/components/hermes/group-chat/GroupChatPanel.vue index 925f6f5..74db0d5 100644 --- a/packages/client/src/components/hermes/group-chat/GroupChatPanel.vue +++ b/packages/client/src/components/hermes/group-chat/GroupChatPanel.vue @@ -1,5 +1,5 @@