fix virtual message list scrolling (#1089)
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user