Merge branch 'codex/active-session-live-state' into dev

This commit is contained in:
ekko
2026-04-19 22:52:41 +08:00
4 changed files with 223 additions and 6 deletions
@@ -80,6 +80,16 @@ function sourceSortKey(source: string): number {
return 0
}
function sortSessionsWithActiveFirst(items: Session[]): Session[] {
return [...items].sort((a, b) => {
const aLive = chatStore.isSessionLive(a.id)
const bLive = chatStore.isSessionLive(b.id)
if (aLive !== bLive) return aLive ? -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<SessionGroup[]>(() => {
const all = [...chatStore.sessions].sort((a, b) => b.createdAt - a.createdAt)
const map = new Map<string, Session[]>()
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 aHasLive = map.get(a)?.some(s => chatStore.isSessionLive(s.id)) || false
const bHasLive = map.get(b)?.some(s => chatStore.isSessionLive(s.id)) || false
if (aHasLive !== bHasLive) return aHasLive ? -1 : 1
const ka = sourceSortKey(a)
const kb = sourceSortKey(b)
if (ka !== kb) return ka - kb
@@ -107,7 +118,7 @@ const groupedSessions = computed<SessionGroup[]>(() => {
return keys.map(key => ({
source: key,
label: key ? getSourceLabel(key) : t('chat.other'),
sessions: map.get(key)!,
sessions: sortSessionsWithActiveFirst(map.get(key)!),
}))
})
@@ -316,12 +327,33 @@ async function handleRenameConfirm() {
v-for="s in group.sessions"
:key="s.id"
class="session-item"
:class="{ active: s.id === chatStore.activeSessionId }"
:class="{ active: chatStore.isSessionLive(s.id) }"
@click="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
>
<div class="session-item-content">
<span class="session-item-title">{{ s.title }}</span>
<span class="session-item-title-row">
<span
v-if="chatStore.isSessionLive(s.id)"
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>
<span class="session-item-meta">
<span v-if="s.model" class="session-item-model">{{ s.model }}</span>
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
@@ -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;
@@ -249,6 +249,10 @@ export const useChatStore = defineStore('chat', () => {
const activeSession = ref<Session | null>(null)
const messages = computed<Message[]>(() => activeSession.value?.messages || [])
function isSessionLive(sessionId: string): boolean {
return streamStates.value.has(sessionId) || resumingRuns.value.has(sessionId)
}
function persistSessionsList() {
// Cache lightweight summaries only (messages are cached per-session).
saveJson(
@@ -912,6 +916,7 @@ export const useChatStore = defineStore('chat', () => {
messages,
isStreaming,
isRunActive,
isSessionLive,
isLoadingSessions,
isLoadingMessages,
newChat,
+144
View File
@@ -0,0 +1,144 @@
// @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,
isSessionLive: vi.fn((sessionId: string) => sessionId === 'discord-active'),
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 slackSession = makeSession('slack-1', {
title: 'Slack Selected',
source: 'slack',
createdAt: 50,
updatedAt: 50,
})
const apiSession = makeSession('api-1', {
title: 'API Session',
source: 'api_server',
createdAt: 300,
updatedAt: 300,
})
mockChatStore.sessions = [apiSession, slackSession, olderDiscord, activeDiscord]
mockChatStore.activeSessionId = apiSession.id
mockChatStore.activeSession = apiSession
mockChatStore.isLoadingSessions = false
mockChatStore.isSessionLive.mockImplementation((sessionId: string) => sessionId === activeDiscord.id)
mockChatStore.switchSession.mockImplementation((sessionId: string) => {
mockChatStore.activeSessionId = sessionId
mockChatStore.activeSession = mockChatStore.sessions.find(s => s.id === sessionId) ?? null
})
})
it('pins the live session group to the top and keeps the indicator on the runtime live session', async () => {
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)
await wrapper.findAll('.session-item').find(node => node.text().includes('Slack Selected'))!.trigger('click')
expect(mockChatStore.switchSession).toHaveBeenCalledWith('slack-1')
const groupLabelsAfterClick = wrapper.findAll('.session-group-label').map(node => node.text())
expect(groupLabelsAfterClick[0]).toBe('Discord')
const activeTitlesAfterClick = wrapper.findAll('.session-item.active .session-item-title').map(node => node.text())
expect(activeTitlesAfterClick).toEqual(['Discord Active'])
})
})
+2
View File
@@ -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'),