fix chat scroll jitter (#1146)
This commit is contained in:
@@ -16,6 +16,7 @@ const chatStore = useChatStore();
|
||||
const { toolTraceVisible } = useToolTraceVisibility();
|
||||
const { t } = useI18n();
|
||||
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
|
||||
const pendingBottomSessionId = ref<string | null>(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({
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
if (scrollParent && shouldKeepBottom) {
|
||||
nextTick(() => {
|
||||
const scrollParent = getScrollParent(markdownBody.value)
|
||||
if (scrollParent) {
|
||||
scrollParent.scrollTop = scrollParent.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
cleanupMermaidRenderArtifacts(`${componentId}-${generation}-${index}`)
|
||||
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage, NInput, NButton, NSpace, NSelect, NPopover, NPopconfirm, NInputNumber, NDropdown, type DropdownOption } from 'naive-ui'
|
||||
@@ -317,12 +317,6 @@ async function handleApproval(choice: 'once' | 'session' | 'always' | 'deny') {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll on new messages
|
||||
const messageListRef = ref()
|
||||
watch(() => store.sortedMessages.length, async () => {
|
||||
await nextTick()
|
||||
messageListRef.value?.scrollToBottom()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -464,7 +458,7 @@ watch(() => store.sortedMessages.length, async () => {
|
||||
</div>
|
||||
|
||||
<template v-if="hasRoom">
|
||||
<GroupMessageList ref="messageListRef" />
|
||||
<GroupMessageList />
|
||||
<div v-if="store.contextStatuses.size > 0 || (store.typingText && store.contextStatuses.size === 0)" class="status-bar">
|
||||
<div v-if="store.contextStatuses.size > 0" class="context-status-list">
|
||||
<div v-for="[name, status] in store.contextStatuses" :key="name" class="context-status">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
import { computed, onMounted, ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||
@@ -10,15 +10,19 @@ const store = useGroupChatStore()
|
||||
const { t } = useI18n()
|
||||
const { toolTraceVisible } = useToolTraceVisibility()
|
||||
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'))
|
||||
let pendingInitialBottomRoomId: string | null = store.currentRoomId
|
||||
|
||||
function checkNearBottom(): void {
|
||||
isNearBottom.value = listRef.value?.isNearBottom(200) ?? true
|
||||
type BottomScrollOptions = number | {
|
||||
frames?: number
|
||||
keepAliveMs?: number
|
||||
}
|
||||
|
||||
function scrollToBottom(): void {
|
||||
listRef.value?.scrollToBottom()
|
||||
function scrollToBottom(options?: BottomScrollOptions): void {
|
||||
const list = listRef.value as (InstanceType<typeof VirtualMessageList> & {
|
||||
scrollToBottom: (options?: BottomScrollOptions) => void
|
||||
}) | null
|
||||
list?.scrollToBottom(options)
|
||||
}
|
||||
|
||||
async function handleTopReach(): Promise<void> {
|
||||
@@ -30,13 +34,35 @@ async function handleTopReach(): Promise<void> {
|
||||
listRef.value?.restoreScrollPosition(snapshot)
|
||||
}
|
||||
|
||||
watch(() => store.messages.length, async () => {
|
||||
watch(() => store.currentRoomId, (roomId) => {
|
||||
pendingInitialBottomRoomId = roomId
|
||||
})
|
||||
|
||||
watch(() => displayMessages.value.map(msg => [
|
||||
msg.id,
|
||||
msg.content?.length ?? 0,
|
||||
msg.reasoning?.length ?? 0,
|
||||
msg.reasoning_content?.length ?? 0,
|
||||
msg.toolStatus ?? '',
|
||||
].join(':')).join('|'), async () => {
|
||||
const shouldForceInitialBottom = !!store.currentRoomId &&
|
||||
pendingInitialBottomRoomId === store.currentRoomId &&
|
||||
displayMessages.value.length > 0
|
||||
const shouldScroll = shouldForceInitialBottom || (listRef.value?.isNearBottom(200) ?? true)
|
||||
await nextTick()
|
||||
if (isNearBottom.value) {
|
||||
scrollToBottom()
|
||||
if (shouldScroll) {
|
||||
scrollToBottom(shouldForceInitialBottom ? { frames: 5, keepAliveMs: 700 } : { frames: 1, keepAliveMs: 120 })
|
||||
if (shouldForceInitialBottom) pendingInitialBottomRoomId = null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.currentRoomId || displayMessages.value.length === 0) return
|
||||
pendingInitialBottomRoomId = null
|
||||
await nextTick()
|
||||
scrollToBottom({ frames: 5, keepAliveMs: 700 })
|
||||
})
|
||||
|
||||
defineExpose({ scrollToBottom })
|
||||
</script>
|
||||
|
||||
@@ -47,7 +73,6 @@ defineExpose({ scrollToBottom })
|
||||
:estimated-item-height="170"
|
||||
:row-gap="12"
|
||||
padding="16px 20px"
|
||||
@scroll="checkNearBottom"
|
||||
@top-reach="handleTopReach"
|
||||
>
|
||||
<template #empty>
|
||||
|
||||
Reference in New Issue
Block a user