diff --git a/src/renderer/src/stores/ui/session.ts b/src/renderer/src/stores/ui/session.ts index 0d98c17e9..c37e95743 100644 --- a/src/renderer/src/stores/ui/session.ts +++ b/src/renderer/src/stores/ui/session.ts @@ -45,6 +45,9 @@ export interface SessionGroup { export type GroupMode = 'time' | 'project' +const SIDEBAR_GROUP_MODE_KEY = 'sidebar_group_mode' +const DEFAULT_GROUP_MODE: GroupMode = 'project' + // --- Helper Functions --- function mapSessionStatus(status: string): UISessionStatus { @@ -133,7 +136,7 @@ function groupByProject(sessions: UISession[]): SessionGroup[] { projectMap.get(dir)!.push(session) } return Array.from(projectMap.entries()).map(([dir, sessions]) => ({ - label: dir === '__no_project__' ? 'common.project.none' : (dir.split('/').pop() ?? dir), + label: dir === '__no_project__' ? 'common.project.none' : (dir.split(/[\\/]/).pop() ?? dir), labelKey: dir === '__no_project__' ? 'common.project.none' : undefined, sessions })) @@ -159,15 +162,20 @@ function getContentType(format: 'markdown' | 'html' | 'txt' | 'nowledge-mem'): s export const useSessionStore = defineStore('session', () => { const agentSessionPresenter = usePresenter('agentSessionPresenter') const tabPresenter = usePresenter('tabPresenter') + const configPresenter = usePresenter('configPresenter', { safeCall: false }) const pageRouter = usePageRouterStore() const messageStore = useMessageStore() const myWebContentsId = getCurrentWebContentsId() let rendererReadyNotified = false + let groupModeLoadPromise: Promise | null = null + let groupModeWritePromise: Promise = Promise.resolve() + let hasLoadedGroupMode = false + let groupModeUpdateVersion = 0 // --- State --- const sessions = ref([]) const activeSessionId = ref(null) - const groupMode = ref('time') + const groupMode = ref(DEFAULT_GROUP_MODE) const loading = ref(false) const error = ref(null) @@ -179,6 +187,41 @@ export const useSessionStore = defineStore('session', () => { notifyRendererReady() + const normalizeGroupMode = (value: unknown): GroupMode => + value === 'time' || value === 'project' ? value : DEFAULT_GROUP_MODE + + const loadGroupModePreference = async (): Promise => { + const loadVersion = groupModeUpdateVersion + + try { + const savedGroupMode = await configPresenter.getSetting(SIDEBAR_GROUP_MODE_KEY) + if (groupModeUpdateVersion === loadVersion) { + groupMode.value = normalizeGroupMode(savedGroupMode) + } + } catch (error) { + if (groupModeUpdateVersion === loadVersion) { + groupMode.value = DEFAULT_GROUP_MODE + } + console.warn('[sessionStore] Failed to load sidebar group mode:', error) + } finally { + hasLoadedGroupMode = true + } + } + + const ensureGroupModeLoaded = async (): Promise => { + if (hasLoadedGroupMode) { + return + } + + if (!groupModeLoadPromise) { + groupModeLoadPromise = loadGroupModePreference().finally(() => { + groupModeLoadPromise = null + }) + } + + await groupModeLoadPromise + } + // --- Getters --- const activeSession: ComputedRef = computed(() => sessions.value.find((s) => s.id === activeSessionId.value) @@ -194,6 +237,7 @@ export const useSessionStore = defineStore('session', () => { loading.value = true error.value = null try { + await ensureGroupModeLoaded() const webContentsId = getCurrentWebContentsId() const previousActiveSessionId = activeSessionId.value const [result, activeSession] = await Promise.all([ @@ -382,8 +426,26 @@ export const useSessionStore = defineStore('session', () => { } } - function toggleGroupMode(): void { - groupMode.value = groupMode.value === 'time' ? 'project' : 'time' + async function toggleGroupMode(): Promise { + const previousMode = groupMode.value + groupMode.value = previousMode === 'time' ? 'project' : 'time' + const localVersion = ++groupModeUpdateVersion + + groupModeWritePromise = groupModeWritePromise.then(async () => { + try { + await configPresenter.setSetting(SIDEBAR_GROUP_MODE_KEY, groupMode.value) + if (localVersion !== groupModeUpdateVersion) { + return + } + } catch (error) { + if (localVersion === groupModeUpdateVersion) { + groupMode.value = previousMode + } + console.warn('[sessionStore] Failed to persist sidebar group mode:', error) + } + }) + + await groupModeWritePromise } function getPinnedSessions(agentId: string | null): UISession[] { @@ -438,6 +500,7 @@ export const useSessionStore = defineStore('session', () => { } }) registerStoreCleanup(cleanupIpcBindings) + void ensureGroupModeLoaded() return { sessions, diff --git a/test/renderer/stores/sessionStore.test.ts b/test/renderer/stores/sessionStore.test.ts index 9ca6c5c89..cd1d9469a 100644 --- a/test/renderer/stores/sessionStore.test.ts +++ b/test/renderer/stores/sessionStore.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it, vi } from 'vitest' -const setupStore = async () => { +type SetupStoreOptions = { + initialSettings?: Record + failGetSetting?: boolean + failSetSetting?: boolean +} + +const SIDEBAR_GROUP_MODE_KEY = 'sidebar_group_mode' + +const setupStore = async (options: SetupStoreOptions = {}) => { vi.resetModules() const agentSessionPresenter = { @@ -25,6 +33,21 @@ const setupStore = async () => { goToNewThread: vi.fn(), currentRoute: 'chat' } + const settings = { ...(options.initialSettings ?? {}) } + const configPresenter = { + getSetting: vi.fn(async (key: string) => { + if (options.failGetSetting) { + throw new Error('failed to read setting') + } + return settings[key] as T | undefined + }), + setSetting: vi.fn(async (key: string, value: T) => { + if (options.failSetSetting) { + throw new Error('failed to write setting') + } + settings[key] = value + }) + } const listeners = new Map void>>() vi.doMock('pinia', () => ({ @@ -32,7 +55,11 @@ const setupStore = async () => { })) vi.doMock('@/composables/usePresenter', () => ({ - usePresenter: (name: string) => (name === 'tabPresenter' ? tabPresenter : agentSessionPresenter) + usePresenter: (name: string) => { + if (name === 'tabPresenter') return tabPresenter + if (name === 'configPresenter') return configPresenter + return agentSessionPresenter + } })) vi.doMock('@/stores/ui/pageRouter', () => ({ @@ -67,12 +94,26 @@ const setupStore = async () => { handler(undefined, payload) } } - return { store, clearStreamingState, agentSessionPresenter, pageRouter, emitIpc, SESSION_EVENTS } + return { + store, + settings, + configPresenter, + clearStreamingState, + agentSessionPresenter, + pageRouter, + emitIpc, + SESSION_EVENTS + } } describe('sessionStore.getFilteredGroups', () => { it('hides draft sessions from grouped sidebar lists', async () => { - const { store } = await setupStore() + const { store } = await setupStore({ + initialSettings: { + [SIDEBAR_GROUP_MODE_KEY]: 'time' + } + }) + await store.fetchSessions() const now = Date.now() store.sessions.value = [ @@ -152,6 +193,123 @@ describe('sessionStore.getFilteredGroups', () => { expect(groupIds).toEqual(['normal-1']) expect(pinnedIds).toEqual(['pinned-1']) }) + + it('uses the last path segment for Windows project labels', async () => { + const { store } = await setupStore() + const now = Date.now() + + await store.fetchSessions() + store.sessions.value = [ + { + id: 'windows-1', + title: 'Windows Chat', + agentId: 'deepchat', + status: 'none', + projectDir: 'C:\\Users\\DeepChat\\workspace', + providerId: 'openai', + modelId: 'gpt-4', + isPinned: false, + isDraft: false, + createdAt: now, + updatedAt: now + } + ] + + const groups = store.getFilteredGroups(null) + + expect(groups).toHaveLength(1) + expect(groups[0]?.label).toBe('workspace') + }) +}) + +describe('sessionStore group mode preferences', () => { + it('falls back to project when no saved preference exists', async () => { + const { store } = await setupStore() + + await store.fetchSessions() + + expect(store.groupMode.value).toBe('project') + }) + + it('restores the saved group mode preference', async () => { + const { store } = await setupStore({ + initialSettings: { + [SIDEBAR_GROUP_MODE_KEY]: 'time' + } + }) + + await store.fetchSessions() + + expect(store.groupMode.value).toBe('time') + }) + + it('falls back to project when the saved preference is invalid', async () => { + const { store } = await setupStore({ + initialSettings: { + [SIDEBAR_GROUP_MODE_KEY]: 'invalid-mode' + } + }) + + await store.fetchSessions() + + expect(store.groupMode.value).toBe('project') + }) + + it('persists toggled group mode changes', async () => { + const { store, settings, configPresenter } = await setupStore() + + await store.fetchSessions() + await store.toggleGroupMode() + + expect(store.groupMode.value).toBe('time') + expect(configPresenter.setSetting).toHaveBeenCalledWith(SIDEBAR_GROUP_MODE_KEY, 'time') + expect(settings[SIDEBAR_GROUP_MODE_KEY]).toBe('time') + }) + + it('rolls back the group mode when persistence fails', async () => { + const { store, configPresenter } = await setupStore({ + failSetSetting: true + }) + + await store.fetchSessions() + await store.toggleGroupMode() + + expect(store.groupMode.value).toBe('project') + expect(configPresenter.setSetting).toHaveBeenCalledWith(SIDEBAR_GROUP_MODE_KEY, 'time') + }) + + it('serializes concurrent group mode writes and persists the last toggle', async () => { + const { store, settings, configPresenter } = await setupStore() + const pendingResolvers: Array<() => void> = [] + + await store.fetchSessions() + configPresenter.setSetting.mockImplementation(async (key: string, value: T) => { + await new Promise((resolve) => { + pendingResolvers.push(() => { + settings[key] = value + resolve() + }) + }) + }) + + const firstToggle = store.toggleGroupMode() + const secondToggle = store.toggleGroupMode() + + await Promise.resolve() + + expect(store.groupMode.value).toBe('project') + expect(configPresenter.setSetting).toHaveBeenCalledTimes(1) + + pendingResolvers.shift()?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(configPresenter.setSetting).toHaveBeenCalledTimes(2) + + pendingResolvers.shift()?.() + await Promise.all([firstToggle, secondToggle]) + + expect(settings[SIDEBAR_GROUP_MODE_KEY]).toBe('project') + }) }) describe('sessionStore streaming cleanup', () => {