chore: add v0.4.8 changelog and improve scroll behavior (#234)
* chore: add v0.4.8 changelog and improve scroll behavior - Add v0.4.8 changelog entries for recent fixes - Fix forced scroll to bottom when returning from other tabs - Smooth session switch with loading transition overlay - Auto-scroll to bottom after mermaid diagram rendering - Bump version to 0.4.8 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: replace blob URLs with persistent download URLs and add image preview - Replace blob URLs with /api/hermes/download URLs after upload so attachments survive page refresh - Add click-to-preview overlay for image attachments - Move upload directory from /tmp to ~/.hermes-web-ui/upload Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: replace findLast with reverse+find for ES2022 compat Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump TypeScript lib target from ES2022 to ES2023 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add changelog entries for blob URL fix, image preview and upload dir Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,19 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string):
|
||||
})
|
||||
}
|
||||
|
||||
function getScrollParent(el: HTMLElement | null): HTMLElement | null {
|
||||
if (!el) return null
|
||||
let current: HTMLElement | null = el.parentElement
|
||||
while (current) {
|
||||
const { overflow, overflowY } = getComputedStyle(current)
|
||||
if (overflow === 'auto' || overflow === 'scroll' || overflowY === 'auto' || overflowY === 'scroll') {
|
||||
return current
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function cleanupMermaidRenderArtifacts(id: string): void {
|
||||
document.getElementById(id)?.remove()
|
||||
document.getElementById(`d${id}`)?.remove()
|
||||
@@ -158,6 +171,13 @@ async function renderMermaidDiagrams(): Promise<void> {
|
||||
element.removeAttribute('data-mermaid-pending')
|
||||
element.removeAttribute('data-mermaid-source')
|
||||
element.innerHTML = result.svg
|
||||
// After mermaid renders, scroll the nearest scrollable ancestor to bottom
|
||||
nextTick(() => {
|
||||
const scrollParent = getScrollParent(markdownBody.value)
|
||||
if (scrollParent) {
|
||||
scrollParent.scrollTop = scrollParent.scrollHeight
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
cleanupMermaidRenderArtifacts(`${componentId}-${generation}-${index}`)
|
||||
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
|
||||
@@ -329,6 +349,10 @@ function handleMarkdownClick(event: MouseEvent): void {
|
||||
color: $text-secondary;
|
||||
font-size: 13px;
|
||||
font-family: $font-code;
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,7 @@ const toast = useMessage();
|
||||
|
||||
const isSystem = computed(() => props.message.role === "system");
|
||||
const toolExpanded = ref(false);
|
||||
const previewUrl = ref<string | null>(null);
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -338,6 +339,7 @@ const renderedToolResult = computed(() => {
|
||||
:src="att.url"
|
||||
:alt="att.name"
|
||||
class="msg-attachment-thumb"
|
||||
@click="previewUrl = att.url"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -417,6 +419,11 @@ const renderedToolResult = computed(() => {
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-if="previewUrl" class="image-preview-overlay" @click.self="previewUrl = null">
|
||||
<img :src="previewUrl" class="image-preview-img" @click="previewUrl = null" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -531,6 +538,7 @@ const renderedToolResult = computed(() => {
|
||||
max-width: 200px;
|
||||
max-height: 160px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.msg-attachment-file {
|
||||
@@ -776,6 +784,24 @@ const renderedToolResult = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-preview-img {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.message.user .msg-body {
|
||||
max-width: 100%;
|
||||
|
||||
@@ -11,6 +11,8 @@ const chatStore = useChatStore();
|
||||
const { t } = useI18n();
|
||||
const { isDark } = useTheme();
|
||||
const listRef = ref<HTMLElement>();
|
||||
const isTransitioning = ref(false);
|
||||
let transitionTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const displayMessages = computed(() =>
|
||||
chatStore.messages.filter((m) => m.role !== "tool"),
|
||||
@@ -54,20 +56,61 @@ function scrollToMessage(messageId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Scroll to bottom once when messages are first loaded
|
||||
// Scroll to bottom once when messages are first loaded after session switch
|
||||
let pendingScrollToBottom = false
|
||||
let isInitialLoad = true
|
||||
|
||||
function showTransition(): void {
|
||||
if (isInitialLoad) return
|
||||
if (transitionTimer) clearTimeout(transitionTimer)
|
||||
isTransitioning.value = true
|
||||
}
|
||||
|
||||
function hideTransition(): void {
|
||||
if (transitionTimer) clearTimeout(transitionTimer)
|
||||
transitionTimer = setTimeout(() => {
|
||||
isTransitioning.value = false
|
||||
transitionTimer = null
|
||||
}, 100)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => chatStore.activeSessionId,
|
||||
(id) => {
|
||||
if (!id) return;
|
||||
if (chatStore.focusMessageId) {
|
||||
scrollToMessage(chatStore.focusMessageId);
|
||||
if (isInitialLoad) {
|
||||
isInitialLoad = false
|
||||
if (!chatStore.focusMessageId) {
|
||||
nextTick(() => scrollToBottom())
|
||||
} else {
|
||||
nextTick(() => scrollToMessage(chatStore.focusMessageId!))
|
||||
}
|
||||
return;
|
||||
}
|
||||
scrollToBottom();
|
||||
if (chatStore.focusMessageId) {
|
||||
nextTick(() => scrollToMessage(chatStore.focusMessageId!));
|
||||
return;
|
||||
}
|
||||
pendingScrollToBottom = true
|
||||
showTransition()
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Safety: ensure overlay is always removed once messages load
|
||||
watch(
|
||||
() => chatStore.messages.length,
|
||||
() => {
|
||||
if (pendingScrollToBottom && chatStore.messages.length > 0) {
|
||||
pendingScrollToBottom = false
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
hideTransition()
|
||||
}, 300)
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => chatStore.focusMessageId,
|
||||
(messageId) => {
|
||||
@@ -92,7 +135,15 @@ watch(
|
||||
scrollToMessage(chatStore.focusMessageId);
|
||||
return;
|
||||
}
|
||||
if (!chatStore.isStreaming) { scrollToBottom(); return; }
|
||||
if (pendingScrollToBottom) {
|
||||
pendingScrollToBottom = false
|
||||
// Wait a moment for mermaid diagrams to render, then reveal and scroll
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
hideTransition()
|
||||
}, 300)
|
||||
return
|
||||
}
|
||||
if (!isNearBottom()) return;
|
||||
scrollToBottom();
|
||||
},
|
||||
@@ -102,15 +153,14 @@ watch(currentToolCalls, () => {
|
||||
scrollToMessage(chatStore.focusMessageId);
|
||||
return;
|
||||
}
|
||||
if (!chatStore.isStreaming) { scrollToBottom(); return; }
|
||||
if (!isNearBottom()) return;
|
||||
scrollToBottom();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="listRef" class="message-list">
|
||||
<div v-if="chatStore.messages.length === 0" class="empty-state">
|
||||
<div ref="listRef" class="message-list" :class="{ 'is-transitioning': isTransitioning }">
|
||||
<div v-if="chatStore.messages.length === 0 && !isTransitioning" class="empty-state">
|
||||
<img src="/logo.png" alt="Hermes" class="empty-logo" />
|
||||
<p>{{ t("chat.emptyState") }}</p>
|
||||
</div>
|
||||
@@ -178,10 +228,16 @@ watch(currentToolCalls, () => {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background-color: $bg-card;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
.dark & {
|
||||
background-color: #333333;
|
||||
}
|
||||
|
||||
&.is-transitioning {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
|
||||
Reference in New Issue
Block a user