mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-10 02:30:15 +00:00
936db02b8d
* tune chat history loading controls * tune chat scroll controls --------- Co-authored-by: Codex <codex@openai.com>
251 lines
8.1 KiB
TypeScript
251 lines
8.1 KiB
TypeScript
// @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))
|
|
const mockShouldAutoFollowBottom = 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: () => [] },
|
|
virtualized: { type: Boolean, default: true },
|
|
},
|
|
emits: ['top-reach'],
|
|
setup(_props, { expose }) {
|
|
expose({
|
|
isNearBottom: mockIsNearBottom,
|
|
scrollToBottom: mockScrollToBottom,
|
|
scrollToMessage: mockScrollToMessage,
|
|
scrollToAnchor: mockScrollToAnchor,
|
|
captureScrollPosition: mockCaptureScrollPosition,
|
|
restoreScrollPosition: mockRestoreScrollPosition,
|
|
captureViewportPosition: mockCaptureViewportPosition,
|
|
restoreViewportPosition: mockRestoreViewportPosition,
|
|
shouldAutoFollowBottom: mockShouldAutoFollowBottom,
|
|
})
|
|
},
|
|
template: `
|
|
<div class="virtual-message-list-stub">
|
|
<slot name="before" />
|
|
<slot name="item" v-for="message in messages" :key="message.id" :message="message" />
|
|
</div>
|
|
`,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/components/hermes/chat/MessageItem.vue', () => ({
|
|
default: defineComponent({
|
|
name: 'MessageItem',
|
|
props: { message: { type: Object, required: true } },
|
|
template: '<div class="stub-message" :data-id="message.id">{{ message.content }}</div>',
|
|
}),
|
|
}))
|
|
|
|
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)
|
|
mockShouldAutoFollowBottom.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()
|
|
})
|
|
|
|
it('disables virtual scrolling for the live chat transcript', async () => {
|
|
const chatStore = useChatStore()
|
|
chatStore.activeSessionId = 'plain-scroll-session'
|
|
chatStore.activeSession = makeSession('plain-scroll-session')
|
|
|
|
const wrapper = mount(MessageList, {
|
|
global: {
|
|
stubs: { Transition: false },
|
|
},
|
|
})
|
|
await flushSessionScroll()
|
|
|
|
expect(wrapper.getComponent({ name: 'VirtualMessageList' }).props('virtualized')).toBe(false)
|
|
})
|
|
|
|
it('shows a history link instead of loading more after the live chat message cap', async () => {
|
|
const chatStore = useChatStore()
|
|
const session = makeSession('history-cap-session')
|
|
session.profile = 'default'
|
|
session.loadedMessageCount = 300
|
|
session.messageTotal = 450
|
|
session.hasMoreBefore = true
|
|
chatStore.activeSessionId = session.id
|
|
chatStore.activeSession = session
|
|
const loadOlderSpy = vi.spyOn(chatStore, 'loadOlderMessages')
|
|
|
|
const wrapper = mount(MessageList, {
|
|
global: {
|
|
stubs: { Transition: false },
|
|
},
|
|
})
|
|
await flushSessionScroll()
|
|
|
|
const link = wrapper.get('.history-archive-link')
|
|
expect(link.text()).toBe('chat.viewOlderInHistory')
|
|
expect(link.attributes('href')).toBe('#/hermes/history/session/history-cap-session?profile=default')
|
|
|
|
wrapper.getComponent({ name: 'VirtualMessageList' }).vm.$emit('top-reach')
|
|
await nextTick()
|
|
|
|
expect(loadOlderSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('shows a bottom jump button when the transcript is far from the bottom', async () => {
|
|
const chatStore = useChatStore()
|
|
chatStore.activeSessionId = 'bottom-button-session'
|
|
chatStore.activeSession = makeSession('bottom-button-session')
|
|
mockIsNearBottom.mockImplementation((threshold?: number) => threshold === 1000 ? false : true)
|
|
|
|
const wrapper = mount(MessageList, {
|
|
global: {
|
|
stubs: { Transition: false },
|
|
},
|
|
})
|
|
await flushSessionScroll()
|
|
|
|
const button = wrapper.get('.scroll-bottom-button')
|
|
expect(button.attributes('aria-label')).toBe('chat.scrollToBottom')
|
|
|
|
await button.trigger('click')
|
|
|
|
expect(mockScrollToBottom).toHaveBeenCalledWith({ frames: 4, keepAliveMs: 600 })
|
|
expect(wrapper.find('.scroll-bottom-button').exists()).toBe(false)
|
|
})
|
|
|
|
it('does not force the bottom while streaming after the user scrolls away', async () => {
|
|
const chatStore = useChatStore()
|
|
chatStore.activeSessionId = 'stream-session'
|
|
chatStore.activeSession = makeSession('stream-session')
|
|
chatStore.activeSession.messages = [
|
|
makeMessage('user-message'),
|
|
{ id: 'assistant-message', role: 'assistant', content: 'first', timestamp: Date.now(), isStreaming: true },
|
|
]
|
|
mockShouldAutoFollowBottom.mockReturnValue(false)
|
|
|
|
mount(MessageList, {
|
|
global: {
|
|
stubs: { Transition: false },
|
|
},
|
|
})
|
|
await flushSessionScroll()
|
|
vi.clearAllMocks()
|
|
|
|
chatStore.activeSession.messages[1].content = 'first second'
|
|
await nextTick()
|
|
|
|
expect(mockShouldAutoFollowBottom).toHaveBeenCalled()
|
|
expect(mockScrollToBottom).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses a single non-sticky bottom scroll for streaming updates near the bottom', async () => {
|
|
const chatStore = useChatStore()
|
|
chatStore.activeSessionId = 'stream-bottom-session'
|
|
chatStore.activeSession = makeSession('stream-bottom-session')
|
|
chatStore.activeSession.messages = [
|
|
makeMessage('user-message'),
|
|
{ id: 'assistant-message', role: 'assistant', content: 'first', timestamp: Date.now(), isStreaming: true },
|
|
]
|
|
mockIsNearBottom.mockReturnValue(true)
|
|
|
|
mount(MessageList, {
|
|
global: {
|
|
stubs: { Transition: false },
|
|
},
|
|
})
|
|
await flushSessionScroll()
|
|
vi.clearAllMocks()
|
|
|
|
chatStore.activeSession.messages[1].content = 'first second'
|
|
await nextTick()
|
|
|
|
expect(mockScrollToBottom).toHaveBeenCalledWith({ frames: 1, keepAliveMs: 0 })
|
|
})
|
|
})
|