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 showDrawer = ref(false);
|
||||||
const drawerActiveTab = ref<"terminal" | "files">("files");
|
const drawerActiveTab = ref<"terminal" | "files">("files");
|
||||||
const showOutline = ref(false);
|
const showOutline = ref(false);
|
||||||
|
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null);
|
||||||
|
|
||||||
const currentMode = ref<"chat" | "live">("chat");
|
const currentMode = ref<"chat" | "live">("chat");
|
||||||
|
|
||||||
@@ -72,6 +73,10 @@ function openSessionInNewTab(sessionId: string) {
|
|||||||
window.open(sessionHref(sessionId), "_blank", "noopener,noreferrer");
|
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) {
|
async function handleSessionClick(sessionId: string) {
|
||||||
await router.push({
|
await router.push({
|
||||||
name: "hermes.session",
|
name: "hermes.session",
|
||||||
@@ -1201,9 +1206,13 @@ async function handleSessionModelCustomSubmit() {
|
|||||||
<template v-if="currentMode === 'chat'">
|
<template v-if="currentMode === 'chat'">
|
||||||
<div class="chat-content-wrapper">
|
<div class="chat-content-wrapper">
|
||||||
<div class="chat-main-content">
|
<div class="chat-main-content">
|
||||||
<MessageList />
|
<MessageList ref="messageListRef" />
|
||||||
</div>
|
</div>
|
||||||
<OutlinePanel v-if="showOutline" :messages="chatStore.messages" />
|
<OutlinePanel
|
||||||
|
v-if="showOutline"
|
||||||
|
:messages="chatStore.messages"
|
||||||
|
@navigate="handleOutlineNavigate"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="visibleApproval" class="approval-bar">
|
<div v-if="visibleApproval" class="approval-bar">
|
||||||
<div class="approval-icon" aria-hidden="true">
|
<div class="approval-icon" aria-hidden="true">
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ function scrollToMessage(messageId: string) {
|
|||||||
listRef.value?.scrollToMessage(messageId);
|
listRef.value?.scrollToMessage(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollToAnchor(messageId: string, anchorId: string) {
|
||||||
|
listRef.value?.scrollToAnchor(messageId, anchorId);
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll to bottom on session switch
|
// Scroll to bottom on session switch
|
||||||
watch(
|
watch(
|
||||||
() => activeSession.value?.id,
|
() => activeSession.value?.id,
|
||||||
@@ -81,6 +85,12 @@ watch(
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
scrollToBottom,
|
||||||
|
scrollToMessage,
|
||||||
|
scrollToAnchor,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ function scrollToMessage(messageId: string) {
|
|||||||
listRef.value?.scrollToMessage(messageId);
|
listRef.value?.scrollToMessage(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollToAnchor(messageId: string, anchorId: string) {
|
||||||
|
listRef.value?.scrollToAnchor(messageId, anchorId);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTopReach() {
|
async function handleTopReach() {
|
||||||
const session = chatStore.activeSession;
|
const session = chatStore.activeSession;
|
||||||
if (!session?.hasMoreBefore || session.isLoadingOlderMessages) return;
|
if (!session?.hasMoreBefore || session.isLoadingOlderMessages) return;
|
||||||
@@ -156,6 +160,12 @@ watch(currentToolCalls, () => {
|
|||||||
if (!isNearBottom()) return;
|
if (!isNearBottom()) return;
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
scrollToBottom,
|
||||||
|
scrollToMessage,
|
||||||
|
scrollToAnchor,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { Message } from '@/stores/hermes/chat'
|
import type { Message } from '@/stores/hermes/chat'
|
||||||
|
|
||||||
@@ -16,6 +16,10 @@ const props = defineProps<{
|
|||||||
messages: Message[]
|
messages: Message[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [target: { messageId: string; anchorId: string }]
|
||||||
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
function extractAllHeadings(text: string, messageId: string): OutlineItem[] {
|
function extractAllHeadings(text: string, messageId: string): OutlineItem[] {
|
||||||
@@ -106,20 +110,10 @@ const outlineItems = computed<OutlineItem[]>(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
function scrollToTarget(anchorId: string) {
|
function scrollToTarget(item: OutlineItem) {
|
||||||
console.log('Attempting to scroll to anchor:', anchorId)
|
emit('navigate', {
|
||||||
nextTick(() => {
|
messageId: item.messageId,
|
||||||
const el = document.getElementById(anchorId)
|
anchorId: item.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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -135,7 +129,7 @@ function scrollToTarget(anchorId: string) {
|
|||||||
<div
|
<div
|
||||||
v-if="item.type === 'user'"
|
v-if="item.type === 'user'"
|
||||||
class="outline-item user-item"
|
class="outline-item user-item"
|
||||||
@click="scrollToTarget(item.anchorId)"
|
@click="scrollToTarget(item)"
|
||||||
>
|
>
|
||||||
<div class="user-question">
|
<div class="user-question">
|
||||||
<span class="q-label">Q:</span>
|
<span class="q-label">Q:</span>
|
||||||
@@ -146,7 +140,7 @@ function scrollToTarget(anchorId: string) {
|
|||||||
v-else
|
v-else
|
||||||
class="outline-item outline-heading-item"
|
class="outline-item outline-heading-item"
|
||||||
:class="`level-${item.level}`"
|
:class="`level-${item.level}`"
|
||||||
@click="scrollToTarget(item.anchorId)"
|
@click="scrollToTarget(item)"
|
||||||
>
|
>
|
||||||
<div class="heading-item">
|
<div class="heading-item">
|
||||||
<span class="heading-text">{{ item.content }}</span>
|
<span class="heading-text">{{ item.content }}</span>
|
||||||
|
|||||||
@@ -50,6 +50,29 @@ function itemHeight(key: string): number {
|
|||||||
return measuredHeights.get(key) || props.estimatedItemHeight;
|
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(() => {
|
const layout = computed(() => {
|
||||||
heightVersion.value;
|
heightVersion.value;
|
||||||
const offsets: number[] = [];
|
const offsets: number[] = [];
|
||||||
@@ -115,18 +138,11 @@ function setItemRef(key: string, el: Element | ComponentPublicInstance | null) {
|
|||||||
|
|
||||||
observedElements.set(key, el);
|
observedElements.set(key, el);
|
||||||
if (typeof ResizeObserver === "undefined") {
|
if (typeof ResizeObserver === "undefined") {
|
||||||
const height = Math.ceil(el.getBoundingClientRect().height || props.estimatedItemHeight);
|
setMeasuredHeight(key, measuredRowHeight(el));
|
||||||
measuredHeights.set(key, height);
|
|
||||||
heightVersion.value += 1;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new ResizeObserver(entries => {
|
const observer = new ResizeObserver(() => setMeasuredHeight(key, measuredRowHeight(el)));
|
||||||
const height = Math.ceil(entries[0]?.contentRect.height || props.estimatedItemHeight);
|
|
||||||
if (measuredHeights.get(key) === height) return;
|
|
||||||
measuredHeights.set(key, height);
|
|
||||||
heightVersion.value += 1;
|
|
||||||
});
|
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
observers.set(key, observer);
|
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() {
|
function captureScrollPosition() {
|
||||||
const el = scrollerRef.value;
|
const el = scrollerRef.value;
|
||||||
if (!el) return null;
|
if (!el) return null;
|
||||||
@@ -218,6 +251,13 @@ watch(messageKeys, keys => {
|
|||||||
observers.delete(key);
|
observers.delete(key);
|
||||||
observedElements.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);
|
nextTick(syncViewport);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,6 +265,7 @@ defineExpose({
|
|||||||
isNearBottom,
|
isNearBottom,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
scrollToMessage,
|
scrollToMessage,
|
||||||
|
scrollToAnchor,
|
||||||
captureScrollPosition,
|
captureScrollPosition,
|
||||||
restoreScrollPosition,
|
restoreScrollPosition,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const hermesSessionsLoaded = ref(false)
|
|||||||
const historySessionId = ref<string | null>(null)
|
const historySessionId = ref<string | null>(null)
|
||||||
const historySession = ref<Session | null>(null)
|
const historySession = ref<Session | null>(null)
|
||||||
const showOutline = ref(false)
|
const showOutline = ref(false)
|
||||||
|
const historyMessageListRef = ref<InstanceType<typeof HistoryMessageList> | null>(null)
|
||||||
const isBatchMode = ref(false)
|
const isBatchMode = ref(false)
|
||||||
const isBatchDeleting = ref(false)
|
const isBatchDeleting = ref(false)
|
||||||
const showBatchDeleteConfirm = ref(false)
|
const showBatchDeleteConfirm = ref(false)
|
||||||
@@ -52,6 +53,10 @@ const contextMenuX = ref(0)
|
|||||||
const contextMenuY = ref(0)
|
const contextMenuY = ref(0)
|
||||||
let hermesSessionsRequestId = 0
|
let hermesSessionsRequestId = 0
|
||||||
|
|
||||||
|
function handleOutlineNavigate(target: { messageId: string; anchorId: string }) {
|
||||||
|
historyMessageListRef.value?.scrollToAnchor(target.messageId, target.anchorId)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadHermesSessions() {
|
async function loadHermesSessions() {
|
||||||
const requestId = ++hermesSessionsRequestId
|
const requestId = ++hermesSessionsRequestId
|
||||||
hermesSessionsLoading.value = true
|
hermesSessionsLoading.value = true
|
||||||
@@ -759,9 +764,13 @@ function handleBatchDeleteConfirm() {
|
|||||||
|
|
||||||
<div class="history-content-wrapper">
|
<div class="history-content-wrapper">
|
||||||
<div class="history-main-content">
|
<div class="history-main-content">
|
||||||
<HistoryMessageList :session="historySession" />
|
<HistoryMessageList ref="historyMessageListRef" :session="historySession" />
|
||||||
</div>
|
</div>
|
||||||
<OutlinePanel v-if="showOutline && historySession" :messages="historySession.messages || []" />
|
<OutlinePanel
|
||||||
|
v-if="showOutline && historySession"
|
||||||
|
:messages="historySession.messages || []"
|
||||||
|
@navigate="handleOutlineNavigate"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user