feat: surface active chat sessions
This commit is contained in:
@@ -80,6 +80,16 @@ function sourceSortKey(source: string): number {
|
|||||||
return 0
|
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
|
// Group sessions by source, with sort order
|
||||||
interface SessionGroup {
|
interface SessionGroup {
|
||||||
source: string
|
source: string
|
||||||
@@ -88,16 +98,17 @@ interface SessionGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groupedSessions = computed<SessionGroup[]>(() => {
|
const groupedSessions = computed<SessionGroup[]>(() => {
|
||||||
const all = [...chatStore.sessions].sort((a, b) => b.createdAt - a.createdAt)
|
|
||||||
|
|
||||||
const map = new Map<string, Session[]>()
|
const map = new Map<string, Session[]>()
|
||||||
for (const s of all) {
|
for (const s of chatStore.sessions) {
|
||||||
const key = s.source || ''
|
const key = s.source || ''
|
||||||
if (!map.has(key)) map.set(key, [])
|
if (!map.has(key)) map.set(key, [])
|
||||||
map.get(key)!.push(s)
|
map.get(key)!.push(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = [...map.keys()].sort((a, b) => {
|
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 ka = sourceSortKey(a)
|
||||||
const kb = sourceSortKey(b)
|
const kb = sourceSortKey(b)
|
||||||
if (ka !== kb) return ka - kb
|
if (ka !== kb) return ka - kb
|
||||||
@@ -107,7 +118,7 @@ const groupedSessions = computed<SessionGroup[]>(() => {
|
|||||||
return keys.map(key => ({
|
return keys.map(key => ({
|
||||||
source: key,
|
source: key,
|
||||||
label: key ? getSourceLabel(key) : t('chat.other'),
|
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)"
|
@contextmenu="handleContextMenu($event, s.id)"
|
||||||
>
|
>
|
||||||
<div class="session-item-content">
|
<div class="session-item-content">
|
||||||
|
<span class="session-item-title-row">
|
||||||
|
<span
|
||||||
|
v-if="s.id === chatStore.activeSessionId"
|
||||||
|
class="session-item-active-indicator"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="session-item-active-spinner"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="8" opacity="0.2" />
|
||||||
|
<path d="M20 12a8 8 0 0 0-8-8" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span class="session-item-title">{{ s.title }}</span>
|
<span class="session-item-title">{{ s.title }}</span>
|
||||||
|
</span>
|
||||||
<span class="session-item-meta">
|
<span class="session-item-meta">
|
||||||
<span v-if="s.model" class="session-item-model">{{ s.model }}</span>
|
<span v-if="s.model" class="session-item-model">{{ s.model }}</span>
|
||||||
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
|
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
|
||||||
@@ -589,6 +621,10 @@ async function handleRenameConfirm() {
|
|||||||
color: $text-primary;
|
color: $text-primary;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.active .session-item-title {
|
||||||
|
color: $accent-primary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-item-content {
|
.session-item-content {
|
||||||
@@ -596,6 +632,13 @@ async function handleRenameConfirm() {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-item-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.session-item-title {
|
.session-item-title {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -604,6 +647,19 @@ async function handleRenameConfirm() {
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.session-item-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
@@ -647,6 +703,16 @@ async function handleRenameConfirm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes session-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chat-main {
|
.chat-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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<Record<string, any>>,
|
||||||
|
activeSessionId: null as string | null,
|
||||||
|
activeSession: null as Record<string, any> | 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: '<div class="message-list-mock" />',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/hermes/chat/ChatInput.vue', () => ({
|
||||||
|
default: {
|
||||||
|
template: '<div class="chat-input-mock" />',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('naive-ui', async () => {
|
||||||
|
const actual = await vi.importActual<any>('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<string, any> = {}) {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'packages/client/src'),
|
'@': resolve(__dirname, 'packages/client/src'),
|
||||||
|
|||||||
Reference in New Issue
Block a user