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>
|
||||
|
||||
Reference in New Issue
Block a user