Files
Hermes-ui/packages/client/src/components/hermes/chat/MessageList.vue
T
ekkoandClaude Opus 4.6 0446385a37 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>
2026-04-26 13:28:08 +08:00

358 lines
8.1 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import MessageItem from "./MessageItem.vue";
import { useChatStore } from "@/stores/hermes/chat";
import thinkingVideoLight from "@/assets/thinking-light.mp4";
import thinkingVideoDark from "@/assets/thinking-dark.mp4";
import { useTheme } from "@/composables/useTheme";
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"),
);
const currentToolCalls = computed(() => {
const msgs = chatStore.messages;
// Find the last user message index
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === "user") {
lastUserIdx = i;
break;
}
}
// Only tool calls after the last user message, newest on top
const tools = msgs.filter((m, i) => m.role === "tool" && i > lastUserIdx);
return [...tools].reverse();
});
function isNearBottom(threshold = 200): boolean {
const el = listRef.value;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
}
function scrollToBottom() {
nextTick(() => {
if (listRef.value) {
listRef.value.scrollTop = listRef.value.scrollHeight;
}
});
}
function scrollToMessage(messageId: string) {
nextTick(() => {
const el = document.getElementById(`message-${messageId}`);
if (el) {
el.scrollIntoView({ block: 'center' });
}
});
}
// 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 (isInitialLoad) {
isInitialLoad = false
if (!chatStore.focusMessageId) {
nextTick(() => scrollToBottom())
} else {
nextTick(() => scrollToMessage(chatStore.focusMessageId!))
}
return;
}
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) => {
if (!messageId) return;
scrollToMessage(messageId);
},
);
// When a run starts (user just sent a message), always scroll to bottom once
watch(
() => chatStore.isRunActive,
(v) => {
if (v) scrollToBottom();
},
);
// During streaming, only auto-scroll if the user is already near the bottom
watch(
() => chatStore.messages[chatStore.messages.length - 1]?.content,
() => {
if (chatStore.focusMessageId) {
scrollToMessage(chatStore.focusMessageId);
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();
},
);
watch(currentToolCalls, () => {
if (chatStore.focusMessageId) {
scrollToMessage(chatStore.focusMessageId);
return;
}
if (!isNearBottom()) return;
scrollToBottom();
});
</script>
<template>
<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>
<MessageItem
v-for="msg in displayMessages"
:key="msg.id"
:message="msg"
:highlight="chatStore.focusMessageId === msg.id"
/>
<Transition name="fade">
<div v-if="chatStore.isRunActive" class="streaming-indicator">
<video
:src="isDark ? thinkingVideoDark : thinkingVideoLight"
autoplay
loop
muted
playsinline
class="thinking-video"
/>
<div v-if="currentToolCalls.length > 0" class="tool-calls-panel">
<div
v-for="tc in currentToolCalls"
:key="tc.id"
class="tool-call-item"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
<span class="tool-call-name">{{ tc.toolName }}</span>
<span v-if="tc.toolPreview" class="tool-call-preview">{{
tc.toolPreview
}}</span>
<span
v-if="tc.toolStatus === 'running'"
class="tool-call-spinner"
></span>
<span v-if="tc.toolStatus === 'error'" class="tool-call-error">{{
t("chat.error")
}}</span>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.message-list {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
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 {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: $text-muted;
gap: 12px;
.empty-logo {
width: 48px;
height: 48px;
opacity: 0.25;
}
p {
font-size: 14px;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.streaming-indicator {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 4px;
.thinking-video {
width: 120px;
height: 213px;
border-radius: $radius-md;
object-fit: contain;
flex-shrink: 0;
}
}
.tool-calls-panel {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 213px;
overflow-y: auto;
padding-top: 4px;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.tool-call-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: $text-secondary;
padding: 3px 8px;
background: rgba(0, 0, 0, 0.03);
border-radius: $radius-sm;
.dark & {
background: rgba(255, 255, 255, 0.06);
}
.tool-call-icon {
flex-shrink: 0;
color: $text-muted;
}
.tool-call-name {
font-family: $font-code;
flex-shrink: 0;
}
.tool-call-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
color: $text-muted;
}
}
.tool-call-spinner {
width: 10px;
height: 10px;
border: 1.5px solid $text-muted;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
.tool-call-error {
font-size: 9px;
color: $error;
background: rgba($error, 0.08);
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>