diff --git a/packages/client/src/components/hermes/chat/ChatPanel.vue b/packages/client/src/components/hermes/chat/ChatPanel.vue index 0c14274..4634132 100644 --- a/packages/client/src/components/hermes/chat/ChatPanel.vue +++ b/packages/client/src/components/hermes/chat/ChatPanel.vue @@ -80,6 +80,16 @@ function sourceSortKey(source: string): number { return 0 } +function sortSessionsWithActiveFirst(items: Session[]): Session[] { + return [...items].sort((a, b) => { + const aActive = a.id === chatStore.activeSessionId + const bActive = b.id === chatStore.activeSessionId + if (aActive !== bActive) return aActive ? -1 : 1 + if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt + return b.updatedAt - a.updatedAt + }) +} + // Group sessions by source, with sort order interface SessionGroup { source: string @@ -88,16 +98,17 @@ interface SessionGroup { } const groupedSessions = computed(() => { - const all = [...chatStore.sessions].sort((a, b) => b.createdAt - a.createdAt) - const map = new Map() - for (const s of all) { + for (const s of chatStore.sessions) { const key = s.source || '' if (!map.has(key)) map.set(key, []) map.get(key)!.push(s) } const keys = [...map.keys()].sort((a, b) => { + const aHasActive = map.get(a)?.some(s => s.id === chatStore.activeSessionId) || false + const bHasActive = map.get(b)?.some(s => s.id === chatStore.activeSessionId) || false + if (aHasActive !== bHasActive) return aHasActive ? -1 : 1 const ka = sourceSortKey(a) const kb = sourceSortKey(b) if (ka !== kb) return ka - kb @@ -107,7 +118,7 @@ const groupedSessions = computed(() => { return keys.map(key => ({ source: key, label: key ? getSourceLabel(key) : t('chat.other'), - sessions: map.get(key)!, + sessions: sortSessionsWithActiveFirst(map.get(key)!), })) }) @@ -321,7 +332,28 @@ async function handleRenameConfirm() { @contextmenu="handleContextMenu($event, s.id)" >
- {{ s.title }} + + + {{ s.title }} + {{ s.model }} {{ formatTime(s.createdAt) }} @@ -589,6 +621,10 @@ async function handleRenameConfirm() { color: $text-primary; font-weight: 500; } + + &.active .session-item-title { + color: $accent-primary; + } } .session-item-content { @@ -596,6 +632,13 @@ async function handleRenameConfirm() { overflow: hidden; } +.session-item-title-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + .session-item-title { display: block; font-size: 13px; @@ -604,6 +647,19 @@ async function handleRenameConfirm() { text-overflow: ellipsis; } +.session-item-active-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: $accent-primary; +} + +.session-item-active-spinner { + animation: session-spin 1.1s linear infinite; + filter: drop-shadow(0 0 6px rgba(var(--accent-primary-rgb), 0.35)); +} + .session-item-time { font-size: 11px; color: $text-muted; @@ -647,6 +703,16 @@ async function handleRenameConfirm() { } } +@keyframes session-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + .chat-main { flex: 1; display: flex; diff --git a/tests/client/chat-panel.test.ts b/tests/client/chat-panel.test.ts new file mode 100644 index 0000000..1970b15 --- /dev/null +++ b/tests/client/chat-panel.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +const mockChatStore = vi.hoisted(() => ({ + sessions: [] as Array>, + activeSessionId: null as string | null, + activeSession: null as Record | null, + isLoadingSessions: false, + newChat: vi.fn(), + switchSession: vi.fn(), + deleteSession: vi.fn(), +})) + +vi.mock('@/stores/hermes/chat', () => ({ + useChatStore: () => mockChatStore, +})) + +vi.mock('@/api/hermes/sessions', () => ({ + renameSession: vi.fn(), +})) + +vi.mock('@/components/hermes/chat/MessageList.vue', () => ({ + default: { + template: '
', + }, +})) + +vi.mock('@/components/hermes/chat/ChatInput.vue', () => ({ + default: { + template: '
', + }, +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('naive-ui', async () => { + const actual = await vi.importActual('naive-ui') + return { + ...actual, + useMessage: () => ({ + success: vi.fn(), + error: vi.fn(), + }), + } +}) + +import ChatPanel from '@/components/hermes/chat/ChatPanel.vue' + +function makeSession(id: string, overrides: Record = {}) { + return { + id, + title: id, + source: 'api_server', + messages: [], + createdAt: 1, + updatedAt: 1, + model: 'gpt-4o', + ...overrides, + } +} + +describe('ChatPanel session list', () => { + beforeEach(() => { + window.localStorage.clear() + vi.clearAllMocks() + + const activeDiscord = makeSession('discord-active', { + title: 'Discord Active', + source: 'discord', + createdAt: 100, + updatedAt: 500, + }) + const olderDiscord = makeSession('discord-older', { + title: 'Discord Older', + source: 'discord', + createdAt: 200, + updatedAt: 400, + }) + const apiSession = makeSession('api-1', { + title: 'API Session', + source: 'api_server', + createdAt: 300, + updatedAt: 300, + }) + + mockChatStore.sessions = [apiSession, olderDiscord, activeDiscord] + mockChatStore.activeSessionId = activeDiscord.id + mockChatStore.activeSession = activeDiscord + mockChatStore.isLoadingSessions = false + }) + + it('pins the active session group to the top and renders an active indicator', () => { + const wrapper = mount(ChatPanel, { + global: { + stubs: { + ChatInput: true, + MessageList: true, + NButton: true, + NDropdown: true, + NInput: true, + NModal: true, + NPopconfirm: true, + NTooltip: true, + }, + }, + }) + + const groupLabels = wrapper.findAll('.session-group-label').map(node => node.text()) + expect(groupLabels[0]).toBe('Discord') + + const sessionTitles = wrapper.findAll('.session-item-title').map(node => node.text()) + expect(sessionTitles.slice(0, 2)).toEqual(['Discord Active', 'Discord Older']) + + const activeIndicator = wrapper.find('.session-item.active .session-item-active-indicator') + expect(activeIndicator.exists()).toBe(true) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 38342ea..0e0d0e9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,9 @@ import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' import { resolve } from 'path' export default defineConfig({ + plugins: [vue()], resolve: { alias: { '@': resolve(__dirname, 'packages/client/src'),