fix message list session transitions (#1172)
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user