diff --git a/apps/desktop/electron/profile-session-routing.test.ts b/apps/desktop/electron/profile-session-routing.test.ts index e4d248edfc..69f3c88e49 100644 --- a/apps/desktop/electron/profile-session-routing.test.ts +++ b/apps/desktop/electron/profile-session-routing.test.ts @@ -38,6 +38,20 @@ test('remote sidebar slices preserve the explicit all-profiles scope', () => { ) }) +test('remote sidebar slices fall back to the all-profiles scope and default limits', () => { + for (const searchParams of [new URLSearchParams(), new URLSearchParams({ recents_profile: ' ' })]) { + const slices = buildSidebarSessionSliceParams(searchParams) + + assert.deepEqual( + Object.values(slices).map(params => params.get('profile')), + ['all', 'all', 'all'] + ) + assert.equal(slices.recents.get('limit'), '20') + assert.equal(slices.cron.get('limit'), '50') + assert.equal(slices.messaging.get('limit'), '100') + } +}) + test('primary session reads use the profile-aware request path', async () => { const calls: Array<{ profile: string | null; path: string }> = [] const expected = { sessions: [{ id: 'session-1' }], total: 1, profile_totals: { default: 1 } } diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx index db676dbbd2..b2360bc50b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx @@ -1,4 +1,5 @@ -import { act, renderHook } from '@testing-library/react' +import { act, render, renderHook } from '@testing-library/react' +import { Suspense } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SessionInfo, SidebarSessionsResponse } from '@/hermes' @@ -309,6 +310,46 @@ describe('refreshSessions batches slices into one request', () => { expect(listSidebarSessions).not.toHaveBeenCalled() }) + it('keeps the committed profile active when a later render is discarded', async () => { + const never = new Promise(() => undefined) + let committedRefresh: (() => Promise) | undefined + + /** Expose only callbacks from committed renders; suspended renders are discarded. */ + function Harness({ profileScope, suspend }: { profileScope: string; suspend: boolean }) { + const actions = useSessionListActions({ profileScope }) + + if (suspend) { + throw never + } + + committedRefresh = actions.refreshSessions + + return null + } + + listSidebarSessions.mockResolvedValue(sidebar({ sessions: [] })) + + const view = render( + + + + ) + + const workRefresh = committedRefresh! + + view.rerender( + + + + ) + + await act(async () => { + await workRefresh() + }) + + expect(listSidebarSessions).toHaveBeenCalledWith(expect.objectContaining({ recentsProfile: 'work' })) + }) + it('ignores an in-flight sidebar response after the active profile changes', async () => { const work = deferred() const personal = deferred() @@ -351,7 +392,7 @@ describe('refreshSessions batches slices into one request', () => { expect($messagingSessions.get().map(session => session.id)).toEqual(['personal-signal']) }) - it('scopes the cron-jobs fetch to the active profile (all → unified view)', async () => { + it('scopes the cron-jobs fetch to the active profile', async () => { listSidebarSessions.mockResolvedValue(sidebar({ sessions: [] })) const scoped = renderHook(() => useSessionListActions({ profileScope: 'work' })) @@ -361,7 +402,9 @@ describe('refreshSessions batches slices into one request', () => { }) expect(getCronJobs).toHaveBeenLastCalledWith('work') + }) + it('requests cron jobs for the unified scope', async () => { const unified = renderHook(() => useSessionListActions({ profileScope: '__all__' })) await act(async () => { @@ -450,6 +493,43 @@ describe('messaging profile scope', () => { expect($messagingPlatformTotals.get()).toEqual({ 'work:signal': 2 }) }) + it('keeps rows from every profile when paginating the unified scope', async () => { + setMessagingSessions([row('work-signal', { profile: 'work', source: 'signal' })]) + listAllProfileSessions.mockResolvedValue({ + sessions: [ + row('work-signal', { profile: 'work', source: 'signal' }), + row('personal-signal', { profile: 'personal', source: 'signal' }) + ], + total: 2 + }) + + const { result } = renderHook(() => useSessionListActions({ profileScope: '__all__' })) + + await act(async () => { + await result.current.loadMoreMessagingForPlatform('signal') + }) + + expect(listAllProfileSessions.mock.calls[0][4]).toBe('all') + expect($messagingSessions.get().map(session => session.id)).toEqual(['work-signal', 'personal-signal']) + expect($messagingPlatformTotals.get()).toEqual({ 'all:signal': 2 }) + }) + + it('keeps loaded platform rows when pagination fails', async () => { + const loaded = [row('work-signal', { profile: 'work', source: 'signal' })] + setMessagingSessions(loaded) + setMessagingPlatformTotals({ 'work:signal': 12 }) + listAllProfileSessions.mockRejectedValue(new Error('request failed')) + + const { result } = renderHook(() => useSessionListActions({ profileScope: 'work' })) + + await act(async () => { + await expect(result.current.loadMoreMessagingForPlatform('signal')).resolves.toBeUndefined() + }) + + expect($messagingSessions.get()).toEqual(loaded) + expect($messagingPlatformTotals.get()).toEqual({ 'work:signal': 12 }) + }) + it('keeps resolved platform totals separate across profile switches', async () => { listAllProfileSessions.mockResolvedValue({ sessions: [row('work-signal', { profile: 'work', source: 'signal' })], @@ -515,10 +595,7 @@ describe('messaging profile scope', () => { await newerLoad older.resolve({ - sessions: [ - row('m1', { profile: 'work', source: 'signal' }), - row('m2', { profile: 'work', source: 'signal' }) - ], + sessions: [row('m1', { profile: 'work', source: 'signal' }), row('m2', { profile: 'work', source: 'signal' })], total: 2 }) await olderLoad diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index 9eece2d2ec..bdff1ddb1f 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' import { listAllProfileSessions, listSidebarSessions, type SessionInfo } from '@/hermes' import { sameCronSignature } from '@/lib/session-signatures' @@ -102,10 +102,12 @@ interface UseSessionListActionsArgs { export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) { const profileScopeRef = useRef(profileScope) const loadMoreMessagingRequestRef = useRef>({}) - const refreshCronJobsRequestRef = useRef(0) const refreshMessagingSessionsRequestRef = useRef(0) const refreshSessionsRequestRef = useRef(0) - profileScopeRef.current = profileScope + + useLayoutEffect(() => { + profileScopeRef.current = profileScope + }, [profileScope]) /** Refresh the active profile's messaging-platform sidebar slice. */ const refreshMessagingSessions = useCallback(async () => { @@ -164,14 +166,21 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform && inProfile(s) const loaded = $messagingSessions.get().filter(inPlatform).length - const result = await listAllProfileSessions( - loaded + SIDEBAR_SESSIONS_PAGE_SIZE, - 1, - 'exclude', - 'recent', - sessionProfile, - { source: platform } - ) + let result + + try { + result = await listAllProfileSessions( + loaded + SIDEBAR_SESSIONS_PAGE_SIZE, + 1, + 'exclude', + 'recent', + sessionProfile, + { source: platform } + ) + } catch { + // Non-fatal: leave the platform's loaded rows and total unchanged. + return + } if ( loadMoreMessagingRequestRef.current[requestKey] !== requestId || @@ -202,9 +211,6 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg return } - const requestId = refreshCronJobsRequestRef.current + 1 - refreshCronJobsRequestRef.current = requestId - try { await refreshCronJobsStore(sessionProfile) } catch {