fix message list session transitions (#1172)

This commit is contained in:
ekko
2026-05-31 09:53:58 +08:00
committed by GitHub
parent 96bdf8d1af
commit e5c5f98fbd
6 changed files with 337 additions and 33 deletions
@@ -1,5 +1,16 @@
<script lang="ts">
type HistorySessionScrollSnapshot = {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
wasNearBottom: boolean;
}
const historySessionScrollPositions = new Map<string, HistorySessionScrollSnapshot>();
</script>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from "vue";
import { ref, computed, nextTick, onBeforeUnmount, watch } from "vue";
import { useI18n } from "vue-i18n";
import VirtualMessageList from "./VirtualMessageList.vue";
import MessageItem from "./MessageItem.vue";
@@ -16,10 +27,9 @@ 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);
const pendingInitialScrollSessionId = ref<string | null>(null);
const activeSession = computed(() => props.session || null);
const listInstanceKey = computed(() => activeSession.value?.id ? `history-${activeSession.value.id}` : "history-empty");
const displayMessages = computed(() =>
(activeSession.value?.messages || []).filter((m) => {
@@ -47,6 +57,35 @@ function scrollToAnchor(messageId: string, anchorId: string) {
listRef.value?.scrollToAnchor(messageId, anchorId);
}
function saveSessionScrollPosition(sessionId: string | null | undefined) {
if (!sessionId) return;
const snapshot = listRef.value?.captureViewportPosition() ?? null;
if (snapshot) historySessionScrollPositions.set(sessionId, snapshot);
}
function applyInitialSessionScroll(sessionId: string) {
if (activeSession.value?.id !== sessionId) return;
if (chatStore.focusMessageId) {
pendingInitialScrollSessionId.value = null;
scrollToMessage(chatStore.focusMessageId);
return;
}
const snapshot = historySessionScrollPositions.get(sessionId);
if (snapshot) {
pendingInitialScrollSessionId.value = null;
if (snapshot.wasNearBottom) {
scrollToBottom();
} else {
listRef.value?.restoreViewportPosition(snapshot);
}
return;
}
scrollToBottom();
if ((activeSession.value?.messages.length || 0) > 0) pendingInitialScrollSessionId.value = null;
}
async function handleTopReach() {
const session = activeSession.value;
if (!session?.hasMoreBefore || session.isLoadingOlderMessages || !props.loadOlder) return;
@@ -57,17 +96,14 @@ async function handleTopReach() {
listRef.value?.restoreScrollPosition(snapshot);
}
// Scroll to bottom on session switch
watch(
() => activeSession.value?.id,
(id) => {
async (id, previousId) => {
saveSessionScrollPosition(previousId);
if (!id) return;
pendingBottomSessionId.value = id;
if (chatStore.focusMessageId) {
scrollToMessage(chatStore.focusMessageId);
return;
}
scrollToBottom();
pendingInitialScrollSessionId.value = id;
await nextTick();
applyInitialSessionScroll(id);
},
{ immediate: true },
);
@@ -84,6 +120,7 @@ watch(
watch(
() => (activeSession.value?.messages || [])[((activeSession.value?.messages || []).length - 1)]?.content,
(content) => {
if (pendingInitialScrollSessionId.value === activeSession.value?.id) return;
if (!content) return
if (!isNearBottom()) return;
scrollToBottom();
@@ -95,14 +132,20 @@ watch(
(length) => {
if (length === 0) return
const id = activeSession.value?.id
const shouldForceBottom = !!id && pendingBottomSessionId.value === id
if (!shouldForceBottom && !isNearBottom()) return;
if (shouldForceBottom) pendingBottomSessionId.value = null
if (id && pendingInitialScrollSessionId.value === id) {
applyInitialSessionScroll(id);
return;
}
if (!isNearBottom()) return;
scrollToBottom();
},
{ flush: "post" },
);
onBeforeUnmount(() => {
saveSessionScrollPosition(activeSession.value?.id);
});
defineExpose({
scrollToBottom,
scrollToMessage,
@@ -112,6 +155,7 @@ defineExpose({
<template>
<VirtualMessageList
:key="listInstanceKey"
ref="listRef"
:messages="displayMessages"
@top-reach="handleTopReach"
@@ -1,5 +1,16 @@
<script lang="ts">
type SessionScrollSnapshot = {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
wasNearBottom: boolean;
}
const sessionScrollPositions = new Map<string, SessionScrollSnapshot>();
</script>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from "vue";
import { ref, computed, nextTick, onBeforeUnmount, watch } from "vue";
import { useI18n } from "vue-i18n";
import VirtualMessageList from "./VirtualMessageList.vue";
import MessageItem from "./MessageItem.vue";
@@ -14,7 +25,7 @@ const { t } = useI18n();
const { isDark } = useTheme();
const { toolTraceVisible } = useToolTraceVisibility();
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
const pendingBottomSessionId = ref<string | null>(null);
const pendingInitialScrollSessionId = ref<string | null>(null);
function formatTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
@@ -101,6 +112,35 @@ function scrollToAnchor(messageId: string, anchorId: string) {
listRef.value?.scrollToAnchor(messageId, anchorId);
}
function saveSessionScrollPosition(sessionId: string | null | undefined) {
if (!sessionId) return;
const snapshot = listRef.value?.captureViewportPosition() ?? null;
if (snapshot) sessionScrollPositions.set(sessionId, snapshot);
}
function applyInitialSessionScroll(sessionId: string) {
if (chatStore.activeSessionId !== sessionId) return;
if (chatStore.focusMessageId) {
pendingInitialScrollSessionId.value = null;
scrollToMessage(chatStore.focusMessageId);
return;
}
const snapshot = sessionScrollPositions.get(sessionId);
if (snapshot) {
pendingInitialScrollSessionId.value = null;
if (snapshot.wasNearBottom) {
scrollToBottom();
} else {
listRef.value?.restoreViewportPosition(snapshot);
}
return;
}
scrollToBottom();
if (chatStore.messages.length > 0) pendingInitialScrollSessionId.value = null;
}
async function handleTopReach() {
const session = chatStore.activeSession;
if (!session?.hasMoreBefore || session.isLoadingOlderMessages) return;
@@ -111,17 +151,14 @@ async function handleTopReach() {
listRef.value?.restoreScrollPosition(snapshot);
}
// Scroll to bottom on session switch
watch(
() => chatStore.activeSessionId,
(id) => {
async (id, previousId) => {
saveSessionScrollPosition(previousId);
if (!id) return;
pendingBottomSessionId.value = id;
if (chatStore.focusMessageId) {
scrollToMessage(chatStore.focusMessageId);
return;
}
scrollToBottom();
pendingInitialScrollSessionId.value = id;
await nextTick();
applyInitialSessionScroll(id);
},
{ immediate: true },
);
@@ -129,13 +166,8 @@ watch(
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();
if (!id || pendingInitialScrollSessionId.value !== id || length === 0) return;
applyInitialSessionScroll(id);
},
{ flush: "post" },
);
@@ -160,6 +192,7 @@ watch(
watch(
() => chatStore.messages[chatStore.messages.length - 1]?.content,
() => {
if (pendingInitialScrollSessionId.value === chatStore.activeSessionId) return;
if (chatStore.focusMessageId) {
scrollToMessage(chatStore.focusMessageId);
return;
@@ -169,6 +202,7 @@ watch(
},
);
watch(currentToolCalls, () => {
if (pendingInitialScrollSessionId.value === chatStore.activeSessionId) return;
if (chatStore.focusMessageId) {
scrollToMessage(chatStore.focusMessageId);
return;
@@ -177,6 +211,10 @@ watch(currentToolCalls, () => {
scrollToBottom();
});
onBeforeUnmount(() => {
saveSessionScrollPosition(chatStore.activeSessionId);
});
defineExpose({
scrollToBottom,
scrollToMessage,
@@ -186,6 +224,7 @@ defineExpose({
<template>
<VirtualMessageList
:key="chatStore.activeSessionId || 'chat-empty'"
ref="listRef"
:messages="displayMessages"
@top-reach="handleTopReach"
@@ -24,6 +24,12 @@ type BottomScrollOptions = number | {
frames?: number;
keepAliveMs?: number;
}
type ViewportScrollSnapshot = {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
wasNearBottom: boolean;
}
const props = withDefaults(defineProps<{
messages: VirtualItem[];
@@ -63,6 +69,7 @@ let bottomFrameAttempts = 0;
let anchorFrame: number | null = null;
let anchorToken = 0;
let activeAnchorTarget: AnchorTarget | null = null;
let viewportRestoreFrame: number | null = null;
const messageKeys = computed(() => props.messages.map(messageKey));
const bufferPx = computed(() => Math.max(props.estimatedItemHeight, props.estimatedItemHeight * props.overscan));
@@ -285,6 +292,53 @@ function restoreScrollPosition(snapshot: { scrollTop: number; scrollHeight: numb
});
}
function captureViewportPosition(): ViewportScrollSnapshot | null {
const el = getScrollerElement();
if (!el) return null;
return {
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
wasNearBottom: isNearBottom(64),
};
}
function restoreViewportPosition(snapshot: ViewportScrollSnapshot | null, frames = 4) {
if (!snapshot) return;
keepBottomUntil = 0;
if (bottomFrame != null) {
cancelAnimationFrame(bottomFrame);
bottomFrame = null;
bottomFrameRemaining = 0;
bottomFrameAttempts = 0;
}
if (viewportRestoreFrame != null) cancelAnimationFrame(viewportRestoreFrame);
nextTick(() => {
let remaining = frames;
const step = () => {
const el = getScrollerElement();
if (!el) {
viewportRestoreFrame = null;
return;
}
const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
const nextScrollTop = Math.min(maxScrollTop, Math.max(0, snapshot.scrollTop));
scrollerRef.value?.scrollToPosition(nextScrollTop);
el.scrollTop = nextScrollTop;
syncViewport();
remaining -= 1;
if (remaining <= 0) {
viewportRestoreFrame = null;
return;
}
viewportRestoreFrame = requestAnimationFrame(step);
};
viewportRestoreFrame = requestAnimationFrame(step);
});
}
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
@@ -303,6 +357,7 @@ onBeforeUnmount(() => {
bottomFrameRemaining = 0;
bottomFrameAttempts = 0;
if (anchorFrame != null) cancelAnimationFrame(anchorFrame);
if (viewportRestoreFrame != null) cancelAnimationFrame(viewportRestoreFrame);
resizeObserver?.disconnect();
});
@@ -318,6 +373,8 @@ defineExpose({
scrollToAnchor,
captureScrollPosition,
restoreScrollPosition,
captureViewportPosition,
restoreViewportPosition,
});
</script>
@@ -371,6 +428,7 @@ defineExpose({
min-height: 0;
display: flex;
position: relative;
animation: message-list-fade-in 1.5s ease both;
}
.virtual-message-list {
@@ -405,4 +463,20 @@ defineExpose({
height: 100%;
min-height: 0;
}
@keyframes message-list-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.virtual-message-list-host {
animation: none;
}
}
</style>
@@ -283,6 +283,8 @@ async function syncRouteSession() {
const sessionProfile = routeProfile.value || findHistorySession(sessionId)?.profile || null
const currentProfile = historySession.value?.profile || null
if (historySessionId.value !== sessionId || currentProfile !== sessionProfile) {
historySessionId.value = sessionId
historySession.value = null
await loadHistorySession(sessionId, sessionProfile)
}
}
@@ -0,0 +1,131 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, nextTick } from 'vue'
const mockScrollToBottom = vi.hoisted(() => vi.fn())
const mockScrollToMessage = vi.hoisted(() => vi.fn())
const mockScrollToAnchor = vi.hoisted(() => vi.fn())
const mockCaptureViewportPosition = vi.hoisted(() => vi.fn())
const mockRestoreViewportPosition = vi.hoisted(() => vi.fn())
const mockCaptureScrollPosition = vi.hoisted(() => vi.fn())
const mockRestoreScrollPosition = vi.hoisted(() => vi.fn())
const mockIsNearBottom = vi.hoisted(() => vi.fn(() => true))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/composables/useTheme', () => ({
useTheme: () => ({ isDark: false }),
}))
vi.mock('@/components/hermes/chat/VirtualMessageList.vue', () => ({
default: defineComponent({
name: 'VirtualMessageList',
props: {
messages: { type: Array, default: () => [] },
},
emits: ['top-reach'],
setup(_props, { expose }) {
expose({
isNearBottom: mockIsNearBottom,
scrollToBottom: mockScrollToBottom,
scrollToMessage: mockScrollToMessage,
scrollToAnchor: mockScrollToAnchor,
captureScrollPosition: mockCaptureScrollPosition,
restoreScrollPosition: mockRestoreScrollPosition,
captureViewportPosition: mockCaptureViewportPosition,
restoreViewportPosition: mockRestoreViewportPosition,
})
},
template: `
<div class="virtual-message-list-stub">
<slot name="item" v-for="message in messages" :key="message.id" :message="message" />
</div>
`,
}),
}))
vi.mock('@/components/hermes/chat/MessageItem.vue', () => ({
default: defineComponent({
name: 'MessageItem',
props: { message: { type: Object, required: true } },
template: '<div class="stub-message" :data-id="message.id">{{ message.content }}</div>',
}),
}))
import MessageList from '@/components/hermes/chat/MessageList.vue'
import { useChatStore, type Message, type Session } from '@/stores/hermes/chat'
function makeMessage(id: string): Message {
return { id, role: 'user', content: id, timestamp: Date.now() }
}
function makeSession(id: string): Session {
return {
id,
title: id,
messages: [makeMessage(`${id}-message`)],
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
async function flushSessionScroll() {
await nextTick()
await nextTick()
}
describe('MessageList session scroll position', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockIsNearBottom.mockReturnValue(true)
})
it('restores a previous session scroll position instead of forcing the bottom', async () => {
const chatStore = useChatStore()
chatStore.activeSessionId = 'scroll-session-a'
chatStore.activeSession = makeSession('scroll-session-a')
mount(MessageList, {
global: {
stubs: { Transition: false },
},
})
await flushSessionScroll()
vi.clearAllMocks()
const sessionASnapshot = {
scrollTop: 320,
scrollHeight: 1200,
clientHeight: 500,
wasNearBottom: false,
}
mockCaptureViewportPosition.mockReturnValue(sessionASnapshot)
chatStore.activeSessionId = 'scroll-session-b'
chatStore.activeSession = makeSession('scroll-session-b')
await flushSessionScroll()
expect(mockCaptureViewportPosition).toHaveBeenCalled()
vi.clearAllMocks()
mockCaptureViewportPosition.mockReturnValue({
scrollTop: 40,
scrollHeight: 1000,
clientHeight: 500,
wasNearBottom: false,
})
chatStore.activeSessionId = 'scroll-session-a'
chatStore.activeSession = makeSession('scroll-session-a')
await flushSessionScroll()
expect(mockRestoreViewportPosition).toHaveBeenCalledWith(sessionASnapshot)
expect(mockScrollToBottom).not.toHaveBeenCalled()
})
})
@@ -94,6 +94,20 @@ describe('tool trace visibility', () => {
])
})
it('does not fall back to the live chat session while history session data is loading', () => {
const chatStore = useChatStore()
chatStore.activeSessionId = 'session-1'
chatStore.activeSession = makeSession(sampleMessages)
const wrapper = mount(HistoryMessageList, {
global: {
stubs: { MessageItem: MessageItemStub },
},
})
expect(wrapper.findAll('.stub-message')).toHaveLength(0)
})
it('hides named transcript traces when the toggle is off while keeping live tool stream visible', () => {
useToolTraceVisibility().setToolTraceVisible(false)