fix virtual message list scrolling (#1089)

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
ekko
2026-05-28 15:22:18 +08:00
committed by GitHub
parent d610c3d1b9
commit 9d2e82cd06
6 changed files with 103 additions and 30 deletions
@@ -38,6 +38,7 @@ const { t } = useI18n();
const showDrawer = ref(false);
const drawerActiveTab = ref<"terminal" | "files">("files");
const showOutline = ref(false);
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null);
const currentMode = ref<"chat" | "live">("chat");
@@ -72,6 +73,10 @@ function openSessionInNewTab(sessionId: string) {
window.open(sessionHref(sessionId), "_blank", "noopener,noreferrer");
}
function handleOutlineNavigate(target: { messageId: string; anchorId: string }) {
messageListRef.value?.scrollToAnchor(target.messageId, target.anchorId);
}
async function handleSessionClick(sessionId: string) {
await router.push({
name: "hermes.session",
@@ -1201,9 +1206,13 @@ async function handleSessionModelCustomSubmit() {
<template v-if="currentMode === 'chat'">
<div class="chat-content-wrapper">
<div class="chat-main-content">
<MessageList />
<MessageList ref="messageListRef" />
</div>
<OutlinePanel v-if="showOutline" :messages="chatStore.messages" />
<OutlinePanel
v-if="showOutline"
:messages="chatStore.messages"
@navigate="handleOutlineNavigate"
/>
</div>
<div v-if="visibleApproval" class="approval-bar">
<div class="approval-icon" aria-hidden="true">
@@ -41,6 +41,10 @@ function scrollToMessage(messageId: string) {
listRef.value?.scrollToMessage(messageId);
}
function scrollToAnchor(messageId: string, anchorId: string) {
listRef.value?.scrollToAnchor(messageId, anchorId);
}
// Scroll to bottom on session switch
watch(
() => activeSession.value?.id,
@@ -81,6 +85,12 @@ watch(
scrollToBottom();
},
);
defineExpose({
scrollToBottom,
scrollToMessage,
scrollToAnchor,
});
</script>
<template>
@@ -96,6 +96,10 @@ function scrollToMessage(messageId: string) {
listRef.value?.scrollToMessage(messageId);
}
function scrollToAnchor(messageId: string, anchorId: string) {
listRef.value?.scrollToAnchor(messageId, anchorId);
}
async function handleTopReach() {
const session = chatStore.activeSession;
if (!session?.hasMoreBefore || session.isLoadingOlderMessages) return;
@@ -156,6 +160,12 @@ watch(currentToolCalls, () => {
if (!isNearBottom()) return;
scrollToBottom();
});
defineExpose({
scrollToBottom,
scrollToMessage,
scrollToAnchor,
});
</script>
<template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Message } from '@/stores/hermes/chat'
@@ -16,6 +16,10 @@ const props = defineProps<{
messages: Message[]
}>()
const emit = defineEmits<{
navigate: [target: { messageId: string; anchorId: string }]
}>()
const { t } = useI18n()
function extractAllHeadings(text: string, messageId: string): OutlineItem[] {
@@ -106,20 +110,10 @@ const outlineItems = computed<OutlineItem[]>(() => {
return items
})
function scrollToTarget(anchorId: string) {
console.log('Attempting to scroll to anchor:', anchorId)
nextTick(() => {
const el = document.getElementById(anchorId)
console.log('Found element:', el)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else {
// Debug: log all heading elements with IDs
console.log('All heading elements on page:')
document.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(el => {
console.log(' -', el.id, ':', el.textContent?.slice(0, 50))
})
}
function scrollToTarget(item: OutlineItem) {
emit('navigate', {
messageId: item.messageId,
anchorId: item.anchorId,
})
}
</script>
@@ -135,7 +129,7 @@ function scrollToTarget(anchorId: string) {
<div
v-if="item.type === 'user'"
class="outline-item user-item"
@click="scrollToTarget(item.anchorId)"
@click="scrollToTarget(item)"
>
<div class="user-question">
<span class="q-label">Q:</span>
@@ -146,7 +140,7 @@ function scrollToTarget(anchorId: string) {
v-else
class="outline-item outline-heading-item"
:class="`level-${item.level}`"
@click="scrollToTarget(item.anchorId)"
@click="scrollToTarget(item)"
>
<div class="heading-item">
<span class="heading-text">{{ item.content }}</span>
@@ -50,6 +50,29 @@ function itemHeight(key: string): number {
return measuredHeights.get(key) || props.estimatedItemHeight;
}
function measuredRowHeight(el: HTMLElement): number {
return Math.ceil(el.getBoundingClientRect().height || props.estimatedItemHeight);
}
function setMeasuredHeight(key: string, height: number) {
const oldHeight = itemHeight(key);
if (oldHeight === height) return;
const index = messageKeys.value.indexOf(key);
if (index >= 0) {
const el = scrollerRef.value;
const rowTop = layout.value.offsets[index] || 0;
const delta = height - oldHeight;
if (el && rowTop < scrollTop.value && delta !== 0) {
el.scrollTop = Math.max(0, el.scrollTop + delta);
syncViewport();
}
}
measuredHeights.set(key, height);
heightVersion.value += 1;
}
const layout = computed(() => {
heightVersion.value;
const offsets: number[] = [];
@@ -115,18 +138,11 @@ function setItemRef(key: string, el: Element | ComponentPublicInstance | null) {
observedElements.set(key, el);
if (typeof ResizeObserver === "undefined") {
const height = Math.ceil(el.getBoundingClientRect().height || props.estimatedItemHeight);
measuredHeights.set(key, height);
heightVersion.value += 1;
setMeasuredHeight(key, measuredRowHeight(el));
return;
}
const observer = new ResizeObserver(entries => {
const height = Math.ceil(entries[0]?.contentRect.height || props.estimatedItemHeight);
if (measuredHeights.get(key) === height) return;
measuredHeights.set(key, height);
heightVersion.value += 1;
});
const observer = new ResizeObserver(() => setMeasuredHeight(key, measuredRowHeight(el)));
observer.observe(el);
observers.set(key, observer);
}
@@ -174,6 +190,23 @@ function scrollToMessage(messageId: string) {
});
}
function scrollToAnchor(messageId: string, anchorId: string) {
const index = props.messages.findIndex(message => String(message.id) === messageId);
if (index < 0) return;
nextTick(() => {
const el = scrollerRef.value;
if (!el) return;
el.scrollTop = Math.max(0, (layout.value.offsets[index] || 0) - 24);
syncViewport();
nextTick(() => {
const target = document.getElementById(anchorId) || document.getElementById(`message-${messageId}`);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
syncViewport();
});
});
}
function captureScrollPosition() {
const el = scrollerRef.value;
if (!el) return null;
@@ -218,6 +251,13 @@ watch(messageKeys, keys => {
observers.delete(key);
observedElements.delete(key);
}
let droppedHeights = false;
for (const key of [...measuredHeights.keys()]) {
if (activeKeys.has(key)) continue;
measuredHeights.delete(key);
droppedHeights = true;
}
if (droppedHeights) heightVersion.value += 1;
nextTick(syncViewport);
});
@@ -225,6 +265,7 @@ defineExpose({
isNearBottom,
scrollToBottom,
scrollToMessage,
scrollToAnchor,
captureScrollPosition,
restoreScrollPosition,
});
@@ -42,6 +42,7 @@ const hermesSessionsLoaded = ref(false)
const historySessionId = ref<string | null>(null)
const historySession = ref<Session | null>(null)
const showOutline = ref(false)
const historyMessageListRef = ref<InstanceType<typeof HistoryMessageList> | null>(null)
const isBatchMode = ref(false)
const isBatchDeleting = ref(false)
const showBatchDeleteConfirm = ref(false)
@@ -52,6 +53,10 @@ const contextMenuX = ref(0)
const contextMenuY = ref(0)
let hermesSessionsRequestId = 0
function handleOutlineNavigate(target: { messageId: string; anchorId: string }) {
historyMessageListRef.value?.scrollToAnchor(target.messageId, target.anchorId)
}
async function loadHermesSessions() {
const requestId = ++hermesSessionsRequestId
hermesSessionsLoading.value = true
@@ -759,9 +764,13 @@ function handleBatchDeleteConfirm() {
<div class="history-content-wrapper">
<div class="history-main-content">
<HistoryMessageList :session="historySession" />
<HistoryMessageList ref="historyMessageListRef" :session="historySession" />
</div>
<OutlinePanel v-if="showOutline && historySession" :messages="historySession.messages || []" />
<OutlinePanel
v-if="showOutline && historySession"
:messages="historySession.messages || []"
@navigate="handleOutlineNavigate"
/>
</div>
</div>
</div>