diff --git a/src/__tests__/main/ipc/handlers/persistence.test.ts b/src/__tests__/main/ipc/handlers/persistence.test.ts index 559b6bba3..19536e190 100644 --- a/src/__tests__/main/ipc/handlers/persistence.test.ts +++ b/src/__tests__/main/ipc/handlers/persistence.test.ts @@ -141,6 +141,8 @@ describe('persistence IPC handlers', () => { 'settings:set', 'settings:getAll', 'sessions:getAll', + 'sessions:getActiveSessionId', + 'sessions:setActiveSessionId', 'sessions:setAll', 'groups:getAll', 'groups:setAll', @@ -154,6 +156,24 @@ describe('persistence IPC handlers', () => { }); }); + describe('sessions:getActiveSessionId', () => { + it('should return empty string when no active session is set', async () => { + mockSessionsStore.get.mockReturnValue(''); + const handler = handlers.get('sessions:getActiveSessionId'); + const result = await handler!({} as any); + expect(mockSessionsStore.get).toHaveBeenCalledWith('activeSessionId', ''); + expect(result).toBe(''); + }); + }); + + describe('sessions:setActiveSessionId', () => { + it('should persist and retrieve an active session ID', async () => { + const setHandler = handlers.get('sessions:setActiveSessionId'); + await setHandler!({} as any, 'test-session-123'); + expect(mockSessionsStore.set).toHaveBeenCalledWith('activeSessionId', 'test-session-123'); + }); + }); + describe('settings:get', () => { it('should retrieve setting by key', async () => { mockSettingsStore.get.mockReturnValue('dark'); diff --git a/src/__tests__/renderer/hooks/useSessionRestoration.test.ts b/src/__tests__/renderer/hooks/useSessionRestoration.test.ts index f20169f38..80e92e2a8 100644 --- a/src/__tests__/renderer/hooks/useSessionRestoration.test.ts +++ b/src/__tests__/renderer/hooks/useSessionRestoration.test.ts @@ -134,7 +134,11 @@ beforeEach(() => { if (!(window as any).maestro) { (window as any).maestro = {}; } - (window as any).maestro.sessions = { getAll: mockGetAll }; + (window as any).maestro.sessions = { + getAll: mockGetAll, + getActiveSessionId: vi.fn().mockResolvedValue(''), + setActiveSessionId: vi.fn(), + }; (window as any).maestro.groups = { getAll: mockGroupsGetAll }; (window as any).maestro.groupChat = { list: mockGroupChatList }; (window as any).maestro.agents = { @@ -980,6 +984,25 @@ describe('Session & Group loading effect', () => { expect(useSessionStore.getState().activeSessionId).toBe('real-1'); }); + it('restores persisted activeSessionId from disk', async () => { + useSessionStore.setState({ activeSessionId: '' } as any); + const session1 = createMockSession({ id: 'sess-1' }); + const session2 = createMockSession({ id: 'sess-2' }); + mockGetAll.mockResolvedValueOnce([session1, session2]); + // Mock the persisted active session ID to be the second session + (window as any).maestro.sessions.getActiveSessionId = vi.fn().mockResolvedValue('sess-2'); + + renderHook(() => useSessionRestoration()); + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(useSessionStore.getState().activeSessionId).toBe('sess-2'); + // Reset mock + (window as any).maestro.sessions.getActiveSessionId = vi.fn().mockResolvedValue(''); + }); + it('keeps activeSessionId when it matches a loaded session', async () => { useSessionStore.setState({ activeSessionId: 'loaded-1' } as any); const session = createMockSession({ id: 'loaded-1' }); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 245e0d593..21beadf5c 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -212,6 +212,8 @@ const mockMaestro = { get: vi.fn().mockResolvedValue([]), save: vi.fn().mockResolvedValue(undefined), setAll: vi.fn().mockResolvedValue(undefined), + getActiveSessionId: vi.fn().mockResolvedValue(''), + setActiveSessionId: vi.fn().mockResolvedValue(undefined), }, groups: { get: vi.fn().mockResolvedValue([]), diff --git a/src/main/ipc/handlers/persistence.ts b/src/main/ipc/handlers/persistence.ts index 4e86fe3fc..afb874851 100644 --- a/src/main/ipc/handlers/persistence.ts +++ b/src/main/ipc/handlers/persistence.ts @@ -96,6 +96,14 @@ export function registerPersistenceHandlers(deps: PersistenceHandlerDependencies return sessions; }); + ipcMain.handle('sessions:getActiveSessionId', async () => { + return sessionsStore.get('activeSessionId', ''); + }); + + ipcMain.handle('sessions:setActiveSessionId', async (_, id: string) => { + sessionsStore.set('activeSessionId', id); + }); + ipcMain.handle('sessions:setAll', async (_, sessions: StoredSession[]) => { // Get previous sessions to detect changes const previousSessions = sessionsStore.get('sessions', []); diff --git a/src/main/preload/settings.ts b/src/main/preload/settings.ts index 717cf9a4b..62397f14b 100644 --- a/src/main/preload/settings.ts +++ b/src/main/preload/settings.ts @@ -36,6 +36,8 @@ export function createSessionsApi() { return { getAll: () => ipcRenderer.invoke('sessions:getAll'), setAll: (sessions: StoredSession[]) => ipcRenderer.invoke('sessions:setAll', sessions), + getActiveSessionId: () => ipcRenderer.invoke('sessions:getActiveSessionId') as Promise, + setActiveSessionId: (id: string) => ipcRenderer.invoke('sessions:setActiveSessionId', id), }; } diff --git a/src/main/stores/types.ts b/src/main/stores/types.ts index 2f0f88ad5..0d3ced9ec 100644 --- a/src/main/stores/types.ts +++ b/src/main/stores/types.ts @@ -88,6 +88,7 @@ export interface MaestroSettings { export interface SessionsData { sessions: StoredSession[]; + activeSessionId?: string; } // ============================================================================ diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index d9b908729..e24e89056 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -254,6 +254,8 @@ interface MaestroAPI { sessions: { getAll: () => Promise; setAll: (sessions: any[]) => Promise; + getActiveSessionId: () => Promise; + setActiveSessionId: (id: string) => Promise; }; groups: { getAll: () => Promise; diff --git a/src/renderer/hooks/session/useSessionRestoration.ts b/src/renderer/hooks/session/useSessionRestoration.ts index e6fb4647a..3b54cfa57 100644 --- a/src/renderer/hooks/session/useSessionRestoration.ts +++ b/src/renderer/hooks/session/useSessionRestoration.ts @@ -48,10 +48,8 @@ export function useSessionRestoration(): SessionRestorationReturn { // useCallback/useEffect without appearing in dependency arrays. Zustand // store actions returned by getState() are stable singletons that never // change, so the empty deps array is intentional. - const { setSessions, setGroups, setActiveSessionId, setSessionsLoaded } = useMemo( - () => useSessionStore.getState(), - [] - ); + const { setSessions, setGroups, setActiveSessionId, hydrateActiveSessionId, setSessionsLoaded } = + useMemo(() => useSessionStore.getState(), []); const { setGroupChats } = useMemo(() => useGroupChatStore.getState(), []); // --- initialLoadComplete proxy ref --- @@ -430,12 +428,14 @@ export function useSessionRestoration(): SessionRestorationReturn { const restoredSessions = await Promise.all(savedSessions.map((s) => restoreSession(s))); setSessions(restoredSessions); - // Set active session to first session if current activeSessionId is invalid - const activeSessionId = useSessionStore.getState().activeSessionId; - if ( - restoredSessions.length > 0 && - !restoredSessions.find((s) => s.id === activeSessionId) - ) { + // Restore persisted active session ID, falling back to first session. + const savedActiveSessionId = await window.maestro.sessions.getActiveSessionId(); + if (savedActiveSessionId && restoredSessions.find((s) => s.id === savedActiveSessionId)) { + // Saved ID is valid — hydrate locally without writing back to disk + hydrateActiveSessionId(savedActiveSessionId); + } else if (restoredSessions[0]?.id) { + // Saved ID is stale or missing — persist the fallback so it + // doesn't retry the invalid ID on next launch setActiveSessionId(restoredSessions[0].id); } diff --git a/src/renderer/stores/sessionStore.ts b/src/renderer/stores/sessionStore.ts index dff230920..d77f44ca4 100644 --- a/src/renderer/stores/sessionStore.ts +++ b/src/renderer/stores/sessionStore.ts @@ -71,6 +71,12 @@ export interface SessionStoreActions { */ setActiveSessionId: (id: string) => void; + /** + * Set the active session ID from persisted state on startup. + * Updates local state only — does not write back to disk. + */ + hydrateActiveSessionId: (id: string) => void; + /** * Set the active session ID without resetting cycle position. * Used internally by session cycling (Cmd+J/K). @@ -198,7 +204,16 @@ export const useSessionStore = create()((set) => ({ }), // Active session - setActiveSessionId: (id) => set({ activeSessionId: id, cyclePosition: -1 }), + setActiveSessionId: (id) => { + set({ activeSessionId: id, cyclePosition: -1 }); + // Fire-and-forget: persist to disk for restore on next launch. + // Not awaited — UI state must update synchronously; if the write + // fails the only consequence is the session won't be pre-selected + // on next launch (falls back to first session). + window.maestro?.sessions?.setActiveSessionId(id); + }, + + hydrateActiveSessionId: (id) => set({ activeSessionId: id, cyclePosition: -1 }), setActiveSessionIdInternal: (v) => set((s) => ({ activeSessionId: resolve(v, s.activeSessionId) })),