diff --git a/packages/client/src/components/hermes/chat/HistoryMessageList.vue b/packages/client/src/components/hermes/chat/HistoryMessageList.vue index 60ed818..4f54705 100644 --- a/packages/client/src/components/hermes/chat/HistoryMessageList.vue +++ b/packages/client/src/components/hermes/chat/HistoryMessageList.vue @@ -1,5 +1,16 @@ + + + @@ -371,6 +428,7 @@ defineExpose({ min-height: 0; display: flex; position: relative; + animation: message-list-fade-in 1.5s ease both; } .virtual-message-list { @@ -405,4 +463,20 @@ defineExpose({ height: 100%; min-height: 0; } + +@keyframes message-list-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .virtual-message-list-host { + animation: none; + } +} diff --git a/packages/client/src/views/hermes/HistoryView.vue b/packages/client/src/views/hermes/HistoryView.vue index 58e3997..40bd911 100644 --- a/packages/client/src/views/hermes/HistoryView.vue +++ b/packages/client/src/views/hermes/HistoryView.vue @@ -283,6 +283,8 @@ async function syncRouteSession() { const sessionProfile = routeProfile.value || findHistorySession(sessionId)?.profile || null const currentProfile = historySession.value?.profile || null if (historySessionId.value !== sessionId || currentProfile !== sessionProfile) { + historySessionId.value = sessionId + historySession.value = null await loadHistorySession(sessionId, sessionProfile) } } diff --git a/tests/client/message-list-scroll-position.test.ts b/tests/client/message-list-scroll-position.test.ts new file mode 100644 index 0000000..4f6d6c1 --- /dev/null +++ b/tests/client/message-list-scroll-position.test.ts @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { defineComponent, nextTick } from 'vue' + +const mockScrollToBottom = vi.hoisted(() => vi.fn()) +const mockScrollToMessage = vi.hoisted(() => vi.fn()) +const mockScrollToAnchor = vi.hoisted(() => vi.fn()) +const mockCaptureViewportPosition = vi.hoisted(() => vi.fn()) +const mockRestoreViewportPosition = vi.hoisted(() => vi.fn()) +const mockCaptureScrollPosition = vi.hoisted(() => vi.fn()) +const mockRestoreScrollPosition = vi.hoisted(() => vi.fn()) +const mockIsNearBottom = vi.hoisted(() => vi.fn(() => true)) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/composables/useTheme', () => ({ + useTheme: () => ({ isDark: false }), +})) + +vi.mock('@/components/hermes/chat/VirtualMessageList.vue', () => ({ + default: defineComponent({ + name: 'VirtualMessageList', + props: { + messages: { type: Array, default: () => [] }, + }, + emits: ['top-reach'], + setup(_props, { expose }) { + expose({ + isNearBottom: mockIsNearBottom, + scrollToBottom: mockScrollToBottom, + scrollToMessage: mockScrollToMessage, + scrollToAnchor: mockScrollToAnchor, + captureScrollPosition: mockCaptureScrollPosition, + restoreScrollPosition: mockRestoreScrollPosition, + captureViewportPosition: mockCaptureViewportPosition, + restoreViewportPosition: mockRestoreViewportPosition, + }) + }, + template: ` +
+ `, + }), +})) + +vi.mock('@/components/hermes/chat/MessageItem.vue', () => ({ + default: defineComponent({ + name: 'MessageItem', + props: { message: { type: Object, required: true } }, + template: '', + }), +})) + +import MessageList from '@/components/hermes/chat/MessageList.vue' +import { useChatStore, type Message, type Session } from '@/stores/hermes/chat' + +function makeMessage(id: string): Message { + return { id, role: 'user', content: id, timestamp: Date.now() } +} + +function makeSession(id: string): Session { + return { + id, + title: id, + messages: [makeMessage(`${id}-message`)], + createdAt: Date.now(), + updatedAt: Date.now(), + } +} + +async function flushSessionScroll() { + await nextTick() + await nextTick() +} + +describe('MessageList session scroll position', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + mockIsNearBottom.mockReturnValue(true) + }) + + it('restores a previous session scroll position instead of forcing the bottom', async () => { + const chatStore = useChatStore() + chatStore.activeSessionId = 'scroll-session-a' + chatStore.activeSession = makeSession('scroll-session-a') + + mount(MessageList, { + global: { + stubs: { Transition: false }, + }, + }) + await flushSessionScroll() + vi.clearAllMocks() + + const sessionASnapshot = { + scrollTop: 320, + scrollHeight: 1200, + clientHeight: 500, + wasNearBottom: false, + } + mockCaptureViewportPosition.mockReturnValue(sessionASnapshot) + + chatStore.activeSessionId = 'scroll-session-b' + chatStore.activeSession = makeSession('scroll-session-b') + await flushSessionScroll() + expect(mockCaptureViewportPosition).toHaveBeenCalled() + + vi.clearAllMocks() + mockCaptureViewportPosition.mockReturnValue({ + scrollTop: 40, + scrollHeight: 1000, + clientHeight: 500, + wasNearBottom: false, + }) + + chatStore.activeSessionId = 'scroll-session-a' + chatStore.activeSession = makeSession('scroll-session-a') + await flushSessionScroll() + + expect(mockRestoreViewportPosition).toHaveBeenCalledWith(sessionASnapshot) + expect(mockScrollToBottom).not.toHaveBeenCalled() + }) +}) diff --git a/tests/client/tool-trace-visibility.test.ts b/tests/client/tool-trace-visibility.test.ts index 8d3661b..c076d16 100644 --- a/tests/client/tool-trace-visibility.test.ts +++ b/tests/client/tool-trace-visibility.test.ts @@ -94,6 +94,20 @@ describe('tool trace visibility', () => { ]) }) + it('does not fall back to the live chat session while history session data is loading', () => { + const chatStore = useChatStore() + chatStore.activeSessionId = 'session-1' + chatStore.activeSession = makeSession(sampleMessages) + + const wrapper = mount(HistoryMessageList, { + global: { + stubs: { MessageItem: MessageItemStub }, + }, + }) + + expect(wrapper.findAll('.stub-message')).toHaveLength(0) + }) + it('hides named transcript traces when the toggle is off while keeping live tool stream visible', () => { useToolTraceVisibility().setToolTraceVisible(false)