Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 67 additions & 4 deletions src/renderer/src/stores/ui/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}))
Expand All @@ -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<void> | null = null
let groupModeWritePromise: Promise<void> = Promise.resolve()
let hasLoadedGroupMode = false
let groupModeUpdateVersion = 0
Comment on lines +170 to +173
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Serialize the preference writes so the last toggle wins.

groupModeUpdateVersion only guards the in-memory rollback. Two quick toggleGroupMode() calls still issue concurrent setSetting(...) writes, so the slower first write can finish last and leave sidebar_group_mode persisted with the wrong value even though groupMode.value already reflects the newer mode.

Also applies to: 428-442

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/src/stores/ui/session.ts` around lines 170 - 172, Concurrent
toggles call setSetting(...) simultaneously; create a serialized write chain
(e.g., add a new module-level Promise variable like groupModeWritePromise) and
modify toggleGroupMode to: increment groupModeUpdateVersion, capture that
version in a local variable, append the new write to groupModeWritePromise =
(groupModeWritePromise ?? Promise.resolve()).then(async () => { await
setSetting('sidebar_group_mode', groupMode.value); if (localVersion !==
groupModeUpdateVersion) return; }) so writes run sequentially and only the last
captured version is allowed to "win"; apply the same promise-chain+version-guard
pattern to the other block that performs setSetting (the code referenced around
the second occurrence) to ensure the last toggle persists.


// --- State ---
const sessions = ref<UISession[]>([])
const activeSessionId = ref<string | null>(null)
const groupMode = ref<GroupMode>('time')
const groupMode = ref<GroupMode>(DEFAULT_GROUP_MODE)
const loading = ref(false)
const error = ref<string | null>(null)

Expand All @@ -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<void> => {
const loadVersion = groupModeUpdateVersion

try {
const savedGroupMode = await configPresenter.getSetting<GroupMode>(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)
Comment on lines +193 to +205
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

These rollback/error paths don't match the real configPresenter contract.

src/shared/types/presenters/legacy.presenters.d.ts defines getSetting() as T | undefined and setSetting() as void, and src/main/presenter/configPresenter/index.ts catches/logs its own exceptions. In production, Lines 201-205 and Lines 440-445 will not see rejected calls here. The risky part is the save path: if persistence fails, toggleGroupMode() keeps the optimistic UI state instead of rolling back, and the new failSetSetting tests only pass because the mock throws in a way the real presenter does not. Please either surface failures from the presenter explicitly or simplify this store/tests to the current sync, non-throwing API.

Also applies to: 434-445

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/src/stores/ui/session.ts` around lines 193 - 205, The store
currently treats configPresenter.getSetting/setSetting as throwing, but the real
presenter API is synchronous/non-throwing (getSetting returns T|undefined,
setSetting returns void and logs errors), so update the store to match that
contract: remove try/catch rollback paths in loadGroupModePreference and in
toggleGroupMode (referencing loadGroupModePreference, groupModeUpdateVersion,
groupMode, DEFAULT_GROUP_MODE, toggleGroupMode, and
configPresenter.getSetting/setSetting), treat missing savedGroupMode as
undefined and fall back to DEFAULT_GROUP_MODE, and remove test-only behaviors
(failSetSetting) that expect thrown errors — instead adapt tests to the
non-throwing behavior or add an explicit error-returning presenter if you want
to test rollback semantics.

} finally {
hasLoadedGroupMode = true
}
}

const ensureGroupModeLoaded = async (): Promise<void> => {
if (hasLoadedGroupMode) {
return
}

if (!groupModeLoadPromise) {
groupModeLoadPromise = loadGroupModePreference().finally(() => {
groupModeLoadPromise = null
})
}

await groupModeLoadPromise
}

// --- Getters ---
const activeSession: ComputedRef<UISession | undefined> = computed(() =>
sessions.value.find((s) => s.id === activeSessionId.value)
Expand All @@ -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([
Expand Down Expand Up @@ -382,8 +426,26 @@ export const useSessionStore = defineStore('session', () => {
}
}

function toggleGroupMode(): void {
groupMode.value = groupMode.value === 'time' ? 'project' : 'time'
async function toggleGroupMode(): Promise<void> {
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[] {
Expand Down Expand Up @@ -438,6 +500,7 @@ export const useSessionStore = defineStore('session', () => {
}
})
registerStoreCleanup(cleanupIpcBindings)
void ensureGroupModeLoaded()

return {
sessions,
Expand Down
166 changes: 162 additions & 4 deletions test/renderer/stores/sessionStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, expect, it, vi } from 'vitest'

const setupStore = async () => {
type SetupStoreOptions = {
initialSettings?: Record<string, unknown>
failGetSetting?: boolean
failSetSetting?: boolean
}

const SIDEBAR_GROUP_MODE_KEY = 'sidebar_group_mode'

const setupStore = async (options: SetupStoreOptions = {}) => {
vi.resetModules()

const agentSessionPresenter = {
Expand All @@ -25,14 +33,33 @@ const setupStore = async () => {
goToNewThread: vi.fn(),
currentRoute: 'chat'
}
const settings = { ...(options.initialSettings ?? {}) }
const configPresenter = {
getSetting: vi.fn(async <T>(key: string) => {
if (options.failGetSetting) {
throw new Error('failed to read setting')
}
return settings[key] as T | undefined
}),
setSetting: vi.fn(async <T>(key: string, value: T) => {
if (options.failSetSetting) {
throw new Error('failed to write setting')
}
settings[key] = value
})
}
const listeners = new Map<string, Array<(...args: any[]) => void>>()

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

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', () => ({
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 <T>(key: string, value: T) => {
await new Promise<void>((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', () => {
Expand Down
Loading