Skip to content

Commit 624cefc

Browse files
authored
feat(session): persist sidebar group mode preference (#1434)
* feat(session): persist sidebar group mode preference * fix(sidebar): harden session grouping
1 parent 82a69d9 commit 624cefc

File tree

2 files changed

+229
-8
lines changed

2 files changed

+229
-8
lines changed

src/renderer/src/stores/ui/session.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export interface SessionGroup {
4545

4646
export type GroupMode = 'time' | 'project'
4747

48+
const SIDEBAR_GROUP_MODE_KEY = 'sidebar_group_mode'
49+
const DEFAULT_GROUP_MODE: GroupMode = 'project'
50+
4851
// --- Helper Functions ---
4952

5053
function mapSessionStatus(status: string): UISessionStatus {
@@ -133,7 +136,7 @@ function groupByProject(sessions: UISession[]): SessionGroup[] {
133136
projectMap.get(dir)!.push(session)
134137
}
135138
return Array.from(projectMap.entries()).map(([dir, sessions]) => ({
136-
label: dir === '__no_project__' ? 'common.project.none' : (dir.split('/').pop() ?? dir),
139+
label: dir === '__no_project__' ? 'common.project.none' : (dir.split(/[\\/]/).pop() ?? dir),
137140
labelKey: dir === '__no_project__' ? 'common.project.none' : undefined,
138141
sessions
139142
}))
@@ -159,15 +162,20 @@ function getContentType(format: 'markdown' | 'html' | 'txt' | 'nowledge-mem'): s
159162
export const useSessionStore = defineStore('session', () => {
160163
const agentSessionPresenter = usePresenter('agentSessionPresenter')
161164
const tabPresenter = usePresenter('tabPresenter')
165+
const configPresenter = usePresenter('configPresenter', { safeCall: false })
162166
const pageRouter = usePageRouterStore()
163167
const messageStore = useMessageStore()
164168
const myWebContentsId = getCurrentWebContentsId()
165169
let rendererReadyNotified = false
170+
let groupModeLoadPromise: Promise<void> | null = null
171+
let groupModeWritePromise: Promise<void> = Promise.resolve()
172+
let hasLoadedGroupMode = false
173+
let groupModeUpdateVersion = 0
166174

167175
// --- State ---
168176
const sessions = ref<UISession[]>([])
169177
const activeSessionId = ref<string | null>(null)
170-
const groupMode = ref<GroupMode>('time')
178+
const groupMode = ref<GroupMode>(DEFAULT_GROUP_MODE)
171179
const loading = ref(false)
172180
const error = ref<string | null>(null)
173181

@@ -179,6 +187,41 @@ export const useSessionStore = defineStore('session', () => {
179187

180188
notifyRendererReady()
181189

190+
const normalizeGroupMode = (value: unknown): GroupMode =>
191+
value === 'time' || value === 'project' ? value : DEFAULT_GROUP_MODE
192+
193+
const loadGroupModePreference = async (): Promise<void> => {
194+
const loadVersion = groupModeUpdateVersion
195+
196+
try {
197+
const savedGroupMode = await configPresenter.getSetting<GroupMode>(SIDEBAR_GROUP_MODE_KEY)
198+
if (groupModeUpdateVersion === loadVersion) {
199+
groupMode.value = normalizeGroupMode(savedGroupMode)
200+
}
201+
} catch (error) {
202+
if (groupModeUpdateVersion === loadVersion) {
203+
groupMode.value = DEFAULT_GROUP_MODE
204+
}
205+
console.warn('[sessionStore] Failed to load sidebar group mode:', error)
206+
} finally {
207+
hasLoadedGroupMode = true
208+
}
209+
}
210+
211+
const ensureGroupModeLoaded = async (): Promise<void> => {
212+
if (hasLoadedGroupMode) {
213+
return
214+
}
215+
216+
if (!groupModeLoadPromise) {
217+
groupModeLoadPromise = loadGroupModePreference().finally(() => {
218+
groupModeLoadPromise = null
219+
})
220+
}
221+
222+
await groupModeLoadPromise
223+
}
224+
182225
// --- Getters ---
183226
const activeSession: ComputedRef<UISession | undefined> = computed(() =>
184227
sessions.value.find((s) => s.id === activeSessionId.value)
@@ -194,6 +237,7 @@ export const useSessionStore = defineStore('session', () => {
194237
loading.value = true
195238
error.value = null
196239
try {
240+
await ensureGroupModeLoaded()
197241
const webContentsId = getCurrentWebContentsId()
198242
const previousActiveSessionId = activeSessionId.value
199243
const [result, activeSession] = await Promise.all([
@@ -382,8 +426,26 @@ export const useSessionStore = defineStore('session', () => {
382426
}
383427
}
384428

385-
function toggleGroupMode(): void {
386-
groupMode.value = groupMode.value === 'time' ? 'project' : 'time'
429+
async function toggleGroupMode(): Promise<void> {
430+
const previousMode = groupMode.value
431+
groupMode.value = previousMode === 'time' ? 'project' : 'time'
432+
const localVersion = ++groupModeUpdateVersion
433+
434+
groupModeWritePromise = groupModeWritePromise.then(async () => {
435+
try {
436+
await configPresenter.setSetting(SIDEBAR_GROUP_MODE_KEY, groupMode.value)
437+
if (localVersion !== groupModeUpdateVersion) {
438+
return
439+
}
440+
} catch (error) {
441+
if (localVersion === groupModeUpdateVersion) {
442+
groupMode.value = previousMode
443+
}
444+
console.warn('[sessionStore] Failed to persist sidebar group mode:', error)
445+
}
446+
})
447+
448+
await groupModeWritePromise
387449
}
388450

389451
function getPinnedSessions(agentId: string | null): UISession[] {
@@ -438,6 +500,7 @@ export const useSessionStore = defineStore('session', () => {
438500
}
439501
})
440502
registerStoreCleanup(cleanupIpcBindings)
503+
void ensureGroupModeLoaded()
441504

442505
return {
443506
sessions,

test/renderer/stores/sessionStore.test.ts

Lines changed: 162 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { describe, expect, it, vi } from 'vitest'
22

3-
const setupStore = async () => {
3+
type SetupStoreOptions = {
4+
initialSettings?: Record<string, unknown>
5+
failGetSetting?: boolean
6+
failSetSetting?: boolean
7+
}
8+
9+
const SIDEBAR_GROUP_MODE_KEY = 'sidebar_group_mode'
10+
11+
const setupStore = async (options: SetupStoreOptions = {}) => {
412
vi.resetModules()
513

614
const agentSessionPresenter = {
@@ -25,14 +33,33 @@ const setupStore = async () => {
2533
goToNewThread: vi.fn(),
2634
currentRoute: 'chat'
2735
}
36+
const settings = { ...(options.initialSettings ?? {}) }
37+
const configPresenter = {
38+
getSetting: vi.fn(async <T>(key: string) => {
39+
if (options.failGetSetting) {
40+
throw new Error('failed to read setting')
41+
}
42+
return settings[key] as T | undefined
43+
}),
44+
setSetting: vi.fn(async <T>(key: string, value: T) => {
45+
if (options.failSetSetting) {
46+
throw new Error('failed to write setting')
47+
}
48+
settings[key] = value
49+
})
50+
}
2851
const listeners = new Map<string, Array<(...args: any[]) => void>>()
2952

3053
vi.doMock('pinia', () => ({
3154
defineStore: (_id: string, setup: () => unknown) => setup
3255
}))
3356

3457
vi.doMock('@/composables/usePresenter', () => ({
35-
usePresenter: (name: string) => (name === 'tabPresenter' ? tabPresenter : agentSessionPresenter)
58+
usePresenter: (name: string) => {
59+
if (name === 'tabPresenter') return tabPresenter
60+
if (name === 'configPresenter') return configPresenter
61+
return agentSessionPresenter
62+
}
3663
}))
3764

3865
vi.doMock('@/stores/ui/pageRouter', () => ({
@@ -67,12 +94,26 @@ const setupStore = async () => {
6794
handler(undefined, payload)
6895
}
6996
}
70-
return { store, clearStreamingState, agentSessionPresenter, pageRouter, emitIpc, SESSION_EVENTS }
97+
return {
98+
store,
99+
settings,
100+
configPresenter,
101+
clearStreamingState,
102+
agentSessionPresenter,
103+
pageRouter,
104+
emitIpc,
105+
SESSION_EVENTS
106+
}
71107
}
72108

73109
describe('sessionStore.getFilteredGroups', () => {
74110
it('hides draft sessions from grouped sidebar lists', async () => {
75-
const { store } = await setupStore()
111+
const { store } = await setupStore({
112+
initialSettings: {
113+
[SIDEBAR_GROUP_MODE_KEY]: 'time'
114+
}
115+
})
116+
await store.fetchSessions()
76117
const now = Date.now()
77118

78119
store.sessions.value = [
@@ -152,6 +193,123 @@ describe('sessionStore.getFilteredGroups', () => {
152193
expect(groupIds).toEqual(['normal-1'])
153194
expect(pinnedIds).toEqual(['pinned-1'])
154195
})
196+
197+
it('uses the last path segment for Windows project labels', async () => {
198+
const { store } = await setupStore()
199+
const now = Date.now()
200+
201+
await store.fetchSessions()
202+
store.sessions.value = [
203+
{
204+
id: 'windows-1',
205+
title: 'Windows Chat',
206+
agentId: 'deepchat',
207+
status: 'none',
208+
projectDir: 'C:\\Users\\DeepChat\\workspace',
209+
providerId: 'openai',
210+
modelId: 'gpt-4',
211+
isPinned: false,
212+
isDraft: false,
213+
createdAt: now,
214+
updatedAt: now
215+
}
216+
]
217+
218+
const groups = store.getFilteredGroups(null)
219+
220+
expect(groups).toHaveLength(1)
221+
expect(groups[0]?.label).toBe('workspace')
222+
})
223+
})
224+
225+
describe('sessionStore group mode preferences', () => {
226+
it('falls back to project when no saved preference exists', async () => {
227+
const { store } = await setupStore()
228+
229+
await store.fetchSessions()
230+
231+
expect(store.groupMode.value).toBe('project')
232+
})
233+
234+
it('restores the saved group mode preference', async () => {
235+
const { store } = await setupStore({
236+
initialSettings: {
237+
[SIDEBAR_GROUP_MODE_KEY]: 'time'
238+
}
239+
})
240+
241+
await store.fetchSessions()
242+
243+
expect(store.groupMode.value).toBe('time')
244+
})
245+
246+
it('falls back to project when the saved preference is invalid', async () => {
247+
const { store } = await setupStore({
248+
initialSettings: {
249+
[SIDEBAR_GROUP_MODE_KEY]: 'invalid-mode'
250+
}
251+
})
252+
253+
await store.fetchSessions()
254+
255+
expect(store.groupMode.value).toBe('project')
256+
})
257+
258+
it('persists toggled group mode changes', async () => {
259+
const { store, settings, configPresenter } = await setupStore()
260+
261+
await store.fetchSessions()
262+
await store.toggleGroupMode()
263+
264+
expect(store.groupMode.value).toBe('time')
265+
expect(configPresenter.setSetting).toHaveBeenCalledWith(SIDEBAR_GROUP_MODE_KEY, 'time')
266+
expect(settings[SIDEBAR_GROUP_MODE_KEY]).toBe('time')
267+
})
268+
269+
it('rolls back the group mode when persistence fails', async () => {
270+
const { store, configPresenter } = await setupStore({
271+
failSetSetting: true
272+
})
273+
274+
await store.fetchSessions()
275+
await store.toggleGroupMode()
276+
277+
expect(store.groupMode.value).toBe('project')
278+
expect(configPresenter.setSetting).toHaveBeenCalledWith(SIDEBAR_GROUP_MODE_KEY, 'time')
279+
})
280+
281+
it('serializes concurrent group mode writes and persists the last toggle', async () => {
282+
const { store, settings, configPresenter } = await setupStore()
283+
const pendingResolvers: Array<() => void> = []
284+
285+
await store.fetchSessions()
286+
configPresenter.setSetting.mockImplementation(async <T>(key: string, value: T) => {
287+
await new Promise<void>((resolve) => {
288+
pendingResolvers.push(() => {
289+
settings[key] = value
290+
resolve()
291+
})
292+
})
293+
})
294+
295+
const firstToggle = store.toggleGroupMode()
296+
const secondToggle = store.toggleGroupMode()
297+
298+
await Promise.resolve()
299+
300+
expect(store.groupMode.value).toBe('project')
301+
expect(configPresenter.setSetting).toHaveBeenCalledTimes(1)
302+
303+
pendingResolvers.shift()?.()
304+
await new Promise((resolve) => setTimeout(resolve, 0))
305+
306+
expect(configPresenter.setSetting).toHaveBeenCalledTimes(2)
307+
308+
pendingResolvers.shift()?.()
309+
await Promise.all([firstToggle, secondToggle])
310+
311+
expect(settings[SIDEBAR_GROUP_MODE_KEY]).toBe('project')
312+
})
155313
})
156314

157315
describe('sessionStore streaming cleanup', () => {

0 commit comments

Comments
 (0)