fix(desktop): harden profile-scoped refreshes

This commit is contained in:
David Dudok de Wit
2026-08-14 23:02:37 +02:00
committed by Teknium
parent 4f670a3f95
commit 4e4d37f6bf
3 changed files with 117 additions and 20 deletions
@@ -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 } }
@@ -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<never>(() => undefined)
let committedRefresh: (() => Promise<void>) | 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(
<Suspense fallback={null}>
<Harness profileScope="work" suspend={false} />
</Suspense>
)
const workRefresh = committedRefresh!
view.rerender(
<Suspense fallback={null}>
<Harness profileScope="personal" suspend />
</Suspense>
)
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<SidebarSessionsResponse>()
const personal = deferred<SidebarSessionsResponse>()
@@ -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
@@ -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<Record<string, number>>({})
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 {