fix chat scroll jitter (#1146)
This commit is contained in:
@@ -16,6 +16,7 @@ const chatStore = useChatStore();
|
|||||||
const { toolTraceVisible } = useToolTraceVisibility();
|
const { toolTraceVisible } = useToolTraceVisibility();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
|
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
|
// Use provided session or fall back to chatStore's active session
|
||||||
const activeSession = computed(() => props.session || chatStore.activeSession);
|
const activeSession = computed(() => props.session || chatStore.activeSession);
|
||||||
@@ -61,6 +62,7 @@ watch(
|
|||||||
() => activeSession.value?.id,
|
() => activeSession.value?.id,
|
||||||
(id) => {
|
(id) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
pendingBottomSessionId.value = id;
|
||||||
if (chatStore.focusMessageId) {
|
if (chatStore.focusMessageId) {
|
||||||
scrollToMessage(chatStore.focusMessageId);
|
scrollToMessage(chatStore.focusMessageId);
|
||||||
return;
|
return;
|
||||||
@@ -92,9 +94,13 @@ watch(
|
|||||||
() => (activeSession.value?.messages || []).length,
|
() => (activeSession.value?.messages || []).length,
|
||||||
(length) => {
|
(length) => {
|
||||||
if (length === 0) return
|
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();
|
scrollToBottom();
|
||||||
},
|
},
|
||||||
|
{ flush: "post" },
|
||||||
);
|
);
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
|||||||
@@ -241,6 +241,10 @@ function getScrollParent(el: HTMLElement | null): HTMLElement | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNearScrollBottom(el: HTMLElement, threshold = 200): boolean {
|
||||||
|
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold
|
||||||
|
}
|
||||||
|
|
||||||
function cleanupMermaidRenderArtifacts(id: string): void {
|
function cleanupMermaidRenderArtifacts(id: string): void {
|
||||||
document.getElementById(id)?.remove()
|
document.getElementById(id)?.remove()
|
||||||
document.getElementById(`d${id}`)?.remove()
|
document.getElementById(`d${id}`)?.remove()
|
||||||
@@ -312,16 +316,16 @@ async function renderMermaidDiagrams(): Promise<void> {
|
|||||||
cleanupMermaidRenderArtifacts(id)
|
cleanupMermaidRenderArtifacts(id)
|
||||||
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
|
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-pending')
|
||||||
element.removeAttribute('data-mermaid-source')
|
element.removeAttribute('data-mermaid-source')
|
||||||
element.innerHTML = result.svg
|
element.innerHTML = result.svg
|
||||||
// After mermaid renders, scroll the nearest scrollable ancestor to bottom
|
if (scrollParent && shouldKeepBottom) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const scrollParent = getScrollParent(markdownBody.value)
|
|
||||||
if (scrollParent) {
|
|
||||||
scrollParent.scrollTop = scrollParent.scrollHeight
|
scrollParent.scrollTop = scrollParent.scrollHeight
|
||||||
}
|
})
|
||||||
})
|
}
|
||||||
} catch {
|
} catch {
|
||||||
cleanupMermaidRenderArtifacts(`${componentId}-${generation}-${index}`)
|
cleanupMermaidRenderArtifacts(`${componentId}-${generation}-${index}`)
|
||||||
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
|
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ type AnchorTarget = {
|
|||||||
anchorId: string;
|
anchorId: string;
|
||||||
align: AnchorAlign;
|
align: AnchorAlign;
|
||||||
}
|
}
|
||||||
|
type BottomScrollOptions = number | {
|
||||||
|
frames?: number;
|
||||||
|
keepAliveMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
messages: VirtualItem[];
|
messages: VirtualItem[];
|
||||||
@@ -54,6 +58,8 @@ const scrollTop = ref(0);
|
|||||||
const viewportHeight = ref(0);
|
const viewportHeight = ref(0);
|
||||||
let keepBottomUntil = 0;
|
let keepBottomUntil = 0;
|
||||||
let bottomFrame: number | null = null;
|
let bottomFrame: number | null = null;
|
||||||
|
let bottomFrameRemaining = 0;
|
||||||
|
let bottomFrameAttempts = 0;
|
||||||
let anchorFrame: number | null = null;
|
let anchorFrame: number | null = null;
|
||||||
let anchorToken = 0;
|
let anchorToken = 0;
|
||||||
let activeAnchorTarget: AnchorTarget | null = null;
|
let activeAnchorTarget: AnchorTarget | null = null;
|
||||||
@@ -94,35 +100,54 @@ function isNearBottom(threshold = 200): boolean {
|
|||||||
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom(options: BottomScrollOptions = {}) {
|
||||||
keepBottomUntil = Date.now() + 700;
|
const frames = typeof options === "number" ? options : options.frames ?? 5;
|
||||||
|
const keepAliveMs = typeof options === "number" ? 700 : options.keepAliveMs ?? 700;
|
||||||
|
keepBottomUntil = Date.now() + keepAliveMs;
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
scheduleScrollToBottom(3);
|
scheduleScrollToBottom(frames);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setScrollToBottomNow() {
|
function setScrollToBottomNow(): boolean {
|
||||||
const el = getScrollerElement();
|
const el = getScrollerElement();
|
||||||
scrollerRef.value?.scrollToBottom();
|
scrollerRef.value?.scrollToBottom();
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||||
|
syncViewport();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
syncViewport();
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleScrollToBottom(frames = 1) {
|
function scheduleScrollToBottom(frames = 1) {
|
||||||
if (bottomFrame != null) cancelAnimationFrame(bottomFrame);
|
bottomFrameRemaining = Math.max(bottomFrameRemaining, frames);
|
||||||
|
if (bottomFrame != null) return;
|
||||||
|
|
||||||
const step = (remaining: number) => {
|
const step = () => {
|
||||||
setScrollToBottomNow();
|
const scrolled = setScrollToBottomNow();
|
||||||
if (remaining <= 1) {
|
if (scrolled) {
|
||||||
|
bottomFrameAttempts = 0;
|
||||||
|
bottomFrameRemaining -= 1;
|
||||||
|
} else {
|
||||||
|
bottomFrameAttempts += 1;
|
||||||
|
}
|
||||||
|
if (bottomFrameRemaining <= 0) {
|
||||||
bottomFrame = null;
|
bottomFrame = null;
|
||||||
|
bottomFrameRemaining = 0;
|
||||||
|
bottomFrameAttempts = 0;
|
||||||
return;
|
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 {
|
function findTargetElement(messageId: string, anchorId: string): HTMLElement | null {
|
||||||
@@ -275,6 +300,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (bottomFrame != null) cancelAnimationFrame(bottomFrame);
|
if (bottomFrame != null) cancelAnimationFrame(bottomFrame);
|
||||||
|
bottomFrameRemaining = 0;
|
||||||
|
bottomFrameAttempts = 0;
|
||||||
if (anchorFrame != null) cancelAnimationFrame(anchorFrame);
|
if (anchorFrame != null) cancelAnimationFrame(anchorFrame);
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useMessage, NInput, NButton, NSpace, NSelect, NPopover, NPopconfirm, NInputNumber, NDropdown, type DropdownOption } from 'naive-ui'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -464,7 +458,7 @@ watch(() => store.sortedMessages.length, async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="hasRoom">
|
<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 || (store.typingText && store.contextStatuses.size === 0)" class="status-bar">
|
||||||
<div v-if="store.contextStatuses.size > 0" class="context-status-list">
|
<div v-if="store.contextStatuses.size > 0" class="context-status-list">
|
||||||
<div v-for="[name, status] in store.contextStatuses" :key="name" class="context-status">
|
<div v-for="[name, status] in store.contextStatuses" :key="name" class="context-status">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||||
@@ -10,15 +10,19 @@ const store = useGroupChatStore()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { toolTraceVisible } = useToolTraceVisibility()
|
const { toolTraceVisible } = useToolTraceVisibility()
|
||||||
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null)
|
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'))
|
const displayMessages = computed(() => store.sortedMessages.filter(msg => msg.role !== 'tool' || toolTraceVisible.value || msg.toolStatus === 'running'))
|
||||||
|
let pendingInitialBottomRoomId: string | null = store.currentRoomId
|
||||||
|
|
||||||
function checkNearBottom(): void {
|
type BottomScrollOptions = number | {
|
||||||
isNearBottom.value = listRef.value?.isNearBottom(200) ?? true
|
frames?: number
|
||||||
|
keepAliveMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom(): void {
|
function scrollToBottom(options?: BottomScrollOptions): void {
|
||||||
listRef.value?.scrollToBottom()
|
const list = listRef.value as (InstanceType<typeof VirtualMessageList> & {
|
||||||
|
scrollToBottom: (options?: BottomScrollOptions) => void
|
||||||
|
}) | null
|
||||||
|
list?.scrollToBottom(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTopReach(): Promise<void> {
|
async function handleTopReach(): Promise<void> {
|
||||||
@@ -30,13 +34,35 @@ async function handleTopReach(): Promise<void> {
|
|||||||
listRef.value?.restoreScrollPosition(snapshot)
|
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()
|
await nextTick()
|
||||||
if (isNearBottom.value) {
|
if (shouldScroll) {
|
||||||
scrollToBottom()
|
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 })
|
defineExpose({ scrollToBottom })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -47,7 +73,6 @@ defineExpose({ scrollToBottom })
|
|||||||
:estimated-item-height="170"
|
:estimated-item-height="170"
|
||||||
:row-gap="12"
|
:row-gap="12"
|
||||||
padding="16px 20px"
|
padding="16px 20px"
|
||||||
@scroll="checkNearBottom"
|
|
||||||
@top-reach="handleTopReach"
|
@top-reach="handleTopReach"
|
||||||
>
|
>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
|
|||||||
Reference in New Issue
Block a user