diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b7c095ba8626..5527a593a2b6 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -212,6 +212,7 @@ import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-bac import { rehomePrimaryConnection } from './primary-connection-rehome' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import { + buildSidebarSessionSliceParams, fetchPrimaryProfileSessions, fetchRemoteProfileSessions, mergeProfileSessionWindow @@ -12314,36 +12315,7 @@ async function interceptSessionRequestForRemote(request) { return undefined // local fast path → batched endpoint's single DB open } - const recentsProfile = (searchParams.get('recents_profile') || 'all').trim() || 'all' - - const sliceParams = (limitKey, defaultLimit, extra) => { - const sp = new URLSearchParams({ - limit: searchParams.get(limitKey) || defaultLimit, - offset: '0', - min_messages: '1', - archived: 'exclude', - order: 'recent', - ...extra - }) - - return sp - } - - const recentsSp = sliceParams('recents_limit', '20', { profile: recentsProfile }) - const recentsExclude = searchParams.get('recents_exclude') - - if (recentsExclude) { - recentsSp.set('exclude_sources', recentsExclude) - } - - const cronSp = sliceParams('cron_limit', '50', { profile: 'all', source: 'cron' }) - - const messagingSp = sliceParams('messaging_limit', '100', { profile: 'all' }) - const messagingExclude = searchParams.get('messaging_exclude') - - if (messagingExclude) { - messagingSp.set('exclude_sources', messagingExclude) - } + const { recents: recentsSp, cron: cronSp, messaging: messagingSp } = buildSidebarSessionSliceParams(searchParams) const [recents, cron, messaging] = await Promise.all([ fetchProfilesSessionSlice(recentsSp, remoteProfiles), diff --git a/apps/desktop/electron/profile-session-routing.test.ts b/apps/desktop/electron/profile-session-routing.test.ts index a96c84d062f2..69f3c88e499f 100644 --- a/apps/desktop/electron/profile-session-routing.test.ts +++ b/apps/desktop/electron/profile-session-routing.test.ts @@ -3,11 +3,55 @@ import assert from 'node:assert/strict' import { test } from 'vitest' import { + buildSidebarSessionSliceParams, fetchPrimaryProfileSessions, fetchRemoteProfileSessions, mergeProfileSessionWindow } from './profile-session-routing' +test('remote sidebar slices all follow the selected profile', () => { + const slices = buildSidebarSessionSliceParams( + new URLSearchParams({ + recents_profile: 'work-vps', + recents_limit: '30', + cron_limit: '40', + messaging_limit: '50', + recents_exclude: 'cron,signal', + messaging_exclude: 'desktop,cron' + }) + ) + + assert.equal(slices.recents.get('profile'), 'work-vps') + assert.equal(slices.cron.get('profile'), 'work-vps') + assert.equal(slices.messaging.get('profile'), 'work-vps') + assert.equal(slices.recents.get('exclude_sources'), 'cron,signal') + assert.equal(slices.cron.get('source'), 'cron') + assert.equal(slices.messaging.get('exclude_sources'), 'desktop,cron') +}) + +test('remote sidebar slices preserve the explicit all-profiles scope', () => { + const slices = buildSidebarSessionSliceParams(new URLSearchParams({ recents_profile: 'all' })) + + assert.deepEqual( + Object.values(slices).map(params => params.get('profile')), + ['all', 'all', 'all'] + ) +}) + +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/electron/profile-session-routing.ts b/apps/desktop/electron/profile-session-routing.ts index dada65a7615b..29b20e3a4c76 100644 --- a/apps/desktop/electron/profile-session-routing.ts +++ b/apps/desktop/electron/profile-session-routing.ts @@ -77,6 +77,48 @@ export function mergeProfileSessionWindow(rows: unknown[], offset: number, limit return window } +export interface SidebarSessionSliceParams { + cron: URLSearchParams + messaging: URLSearchParams + recents: URLSearchParams +} + +/** Build the three remote-profile sidebar reads from one workspace scope. */ +export function buildSidebarSessionSliceParams(searchParams: URLSearchParams): SidebarSessionSliceParams { + const profile = (searchParams.get('recents_profile') || 'all').trim() || 'all' + + const slice = (limitKey: string, defaultLimit: string, extra: Record) => + new URLSearchParams({ + limit: searchParams.get(limitKey) || defaultLimit, + offset: '0', + min_messages: '1', + archived: 'exclude', + order: 'recent', + ...extra + }) + + const recents = slice('recents_limit', '20', { profile }) + const recentsExclude = searchParams.get('recents_exclude') + + if (recentsExclude) { + recents.set('exclude_sources', recentsExclude) + } + + const messaging = slice('messaging_limit', '100', { profile }) + const messagingExclude = searchParams.get('messaging_exclude') + + if (messagingExclude) { + messaging.set('exclude_sources', messagingExclude) + } + + return { + cron: slice('cron_limit', '50', { profile, source: 'cron' }), + messaging, + recents + } +} + +/** Fetch the primary backend's profile-aware session slice, falling back to an empty result when unavailable. */ export async function fetchPrimaryProfileSessions( searchParams: URLSearchParams, fetchJsonForProfile: FetchJsonForProfile diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 8533b47fc523..82ac170dcb0d 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -78,7 +78,9 @@ import { $profiles, $profileScope, ALL_PROFILES, - normalizeProfileKey + messagingTotalsKey, + normalizeProfileKey, + sidebarProfileForScope } from '@/store/profile' import { $activeProjectId, @@ -144,6 +146,7 @@ import { SidebarCronJobsSection } from './cron-jobs-section' import { SidebarFilterMenu } from './filter-menu' import { SidebarLoadMoreRow } from './load-more-row' import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order' +import { filterSessionsByProfileScope } from './profile-scope' import { ProfileRail } from './profile-switcher' import { ProjectDialog } from './project-dialog' import { @@ -395,6 +398,7 @@ export function ChatSidebar({ // profile while scope is still ALL (persisted), the rail is hidden and they'd // otherwise be stuck in the grouped view with no way out. const showAllProfiles = multiProfile && profileScope === ALL_PROFILES + const messagingProfile = sidebarProfileForScope(profileScope) const agentOrderIds = useStore($sidebarSessionOrderIds) const agentOrderManual = useStore($sidebarSessionOrderManual) const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds) @@ -523,11 +527,21 @@ export function ChatSidebar({ [visibleSessions] ) + const visibleCronSessions = useMemo( + () => filterSessionsByProfileScope(cronSessions, profileScope), + [cronSessions, profileScope] + ) + + const visibleMessagingSessions = useMemo( + () => filterSessionsByProfileScope(messagingSessions, profileScope), + [messagingSessions, profileScope] + ) + // Index sessions by every id a pin might be stored under — recents, cron, // AND messaging, since all three can be pinned (see session-index.ts). const sessionByAnyId = useMemo( - () => buildSessionByAnyId(visibleSessions, cronSessions, messagingSessions), - [visibleSessions, cronSessions, messagingSessions] + () => buildSessionByAnyId(visibleSessions, visibleCronSessions, visibleMessagingSessions), + [visibleSessions, visibleCronSessions, visibleMessagingSessions] ) // Local pin ids first (hand-picked order), then server-flagged pins the @@ -1160,7 +1174,7 @@ export function ChatSidebar({ // within a platform by recency. Per-platform totals (when a "load more" has // resolved them) drive the count + whether more remain on disk. const messagingGroups = useMemo(() => { - if (!messagingSessions.length) { + if (!visibleMessagingSessions.length) { return [] } @@ -1170,7 +1184,7 @@ export function ChatSidebar({ // promises rows that will never appear. const pinnedBySource = new Map() - for (const session of messagingSessions) { + for (const session of visibleMessagingSessions) { const sourceId = normalizeSessionSource(session.source) if (!sourceId) { @@ -1191,7 +1205,7 @@ export function ChatSidebar({ return [...bySource.entries()] .map(([sourceId, list]) => { const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a)) - const known = messagingPlatformTotals[sourceId] + const known = messagingPlatformTotals[messagingTotalsKey(messagingProfile, sourceId)] const unpinnedKnown = known == null ? null : Math.max(0, known - (pinnedBySource.get(sourceId) ?? 0)) const total = Math.max(ordered.length, unpinnedKnown ?? 0) @@ -1207,7 +1221,7 @@ export function ChatSidebar({ } }) .sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0])) - }, [messagingSessions, messagingPlatformTotals, messagingTruncated, isPinnedSession]) + }, [visibleMessagingSessions, messagingPlatformTotals, messagingTruncated, isPinnedSession, messagingProfile]) // Grouping by profile: one collapsible group per profile, color on the header // (not on every row). Default profile floats to the top, the rest alpha. diff --git a/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts b/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts new file mode 100644 index 000000000000..14e73edb615c --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { ALL_PROFILES } from '@/store/profile' +import type { SessionInfo } from '@/types/hermes' + +import { filterSessionsByProfileScope } from './profile-scope' + +/** Build the smallest session row needed by the profile-scope tests. */ +const row = (id: string, profile?: string): SessionInfo => + ({ id, message_count: 1, profile, source: 'signal', started_at: 0, title: id }) as SessionInfo + +describe('filterSessionsByProfileScope', () => { + it('keeps only rows from the selected profile', () => { + const rows = [row('default-row', 'default'), row('work-row', 'work')] + + expect(filterSessionsByProfileScope(rows, 'work').map(session => session.id)).toEqual(['work-row']) + }) + + it('treats legacy rows without a profile as default', () => { + const rows = [row('legacy-row'), row('work-row', 'work')] + + expect(filterSessionsByProfileScope(rows, 'default').map(session => session.id)).toEqual(['legacy-row']) + }) + + it('preserves every row in the canonical all-profiles scope', () => { + const rows = [row('default-row', 'default'), row('work-row', 'work')] + + expect(filterSessionsByProfileScope(rows, ALL_PROFILES)).toBe(rows) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/profile-scope.ts b/apps/desktop/src/app/chat/sidebar/profile-scope.ts new file mode 100644 index 000000000000..45686d82525b --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/profile-scope.ts @@ -0,0 +1,13 @@ +import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile' +import type { SessionInfo } from '@/types/hermes' + +/** Return the sessions visible in one sidebar profile scope, or the original unified list for All profiles. */ +export function filterSessionsByProfileScope(sessions: SessionInfo[], profileScope: string): SessionInfo[] { + if (profileScope === ALL_PROFILES) { + return sessions + } + + const scope = normalizeProfileKey(profileScope) + + return sessions.filter(session => normalizeProfileKey(session.profile) === scope) +} diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/resolve-stored-session.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/resolve-stored-session.test.ts index 41b6117d544b..4e7513a95b7d 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/resolve-stored-session.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/resolve-stored-session.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as HermesModule from '@/hermes' import { getSession } from '@/hermes' import { $activeGatewayProfile, $profiles } from '@/store/profile' -import { $sessions } from '@/store/session' +import { $cronSessions, $messagingSessions, $sessions } from '@/store/session' import type { SessionInfo } from '@/types/hermes' import { resolveSessionProfile, resolveStoredSession } from './utils' @@ -21,6 +21,8 @@ const profiles = (...names: string[]) => names.map(name => ({ name }) as never) describe('resolveStoredSession profile ownership', () => { beforeEach(() => { + $cronSessions.set([]) + $messagingSessions.set([]) $sessions.set([]) $profiles.set(profiles('default', 'meta')) $activeGatewayProfile.set('meta') @@ -28,6 +30,8 @@ describe('resolveStoredSession profile ownership', () => { }) afterEach(() => { + $cronSessions.set([]) + $messagingSessions.set([]) $sessions.set([]) $profiles.set([]) $activeGatewayProfile.set('default') @@ -42,6 +46,19 @@ describe('resolveStoredSession profile ownership', () => { expect(mockGetSession).not.toHaveBeenCalled() }) + it.each([ + ['cron', $cronSessions], + ['messaging', $messagingSessions] + ])('resolves a %s sidebar row without duplicating it into regular sessions', async (_source, store) => { + store.set([session({ id: 's1', profile: 'default' })]) + + const resolved = await resolveStoredSession('s1') + + expect(resolved?.profile).toBe('default') + expect(mockGetSession).not.toHaveBeenCalled() + expect($sessions.get()).toEqual([]) + }) + it('treats a profile-less cache hit as unresolved when multiple profiles exist', async () => { $sessions.set([session({ id: 's1' })]) mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 533f0e9ca907..5fede6010d58 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -7,7 +7,9 @@ import { reconcileApprovalModeForProfile } from '@/store/approval-mode' import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { + $cronSessions, $currentCwd, + $messagingSessions, $sessions, commitWorkspaceCwdForSelectedSession, releaseWorkspaceCwdOwner, @@ -1291,7 +1293,9 @@ function upsertResolvedSession(session: SessionInfo, storedSessionId: string) { } export async function resolveStoredSession(storedSessionId: string): Promise { - const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) + const cached = [...$sessions.get(), ...$cronSessions.get(), ...$messagingSessions.get()].find(session => + sessionMatchesStoredId(session, storedSessionId) + ) // A row with no owning profile can't route a resume when more than one // profile exists — a resume without a profile lands on whichever gateway is 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 60517df41fde..b2360bc50b9a 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,14 +1,19 @@ -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' +import { $cronJobs, setCronJobs } from '@/store/cron' import { $cronSessions, + $messagingPlatformTotals, $messagingSessions, $sessions, $sessionsLoading, setCronSessions, + setMessagingPlatformTotals, setMessagingSessions, + setMessagingTruncated, setSessions, setSessionsLoading } from '@/store/session' @@ -54,10 +59,27 @@ const sidebar = ( const listSidebarSessions = vi.fn() const listAllProfileSessions = vi.fn() +const getCronJobs = vi.fn() + +interface Deferred { + promise: Promise + resolve: (value: T) => void +} + +/** Create a promise whose completion order the stale-response tests control. */ +function deferred(): Deferred { + let resolve!: (value: T) => void + + const promise = new Promise(done => { + resolve = done + }) + + return { promise, resolve } +} vi.mock('@/hermes', async importOriginal => ({ ...(await importOriginal>()), - getCronJobs: vi.fn(async () => []), + getCronJobs: (...args: unknown[]) => getCronJobs(...args), listAllProfileSessions: (...args: unknown[]) => listAllProfileSessions(...args), listSidebarSessions: (...args: unknown[]) => listSidebarSessions(...args) })) @@ -71,19 +93,27 @@ vi.mock('@/store/projects', () => ({ })) beforeEach(() => { + getCronJobs.mockReset() + getCronJobs.mockResolvedValue([]) listSidebarSessions.mockReset() listAllProfileSessions.mockReset() removed.ids = new Set() + setCronJobs([]) setSessions([]) setCronSessions([]) setMessagingSessions([]) + setMessagingPlatformTotals({}) + setMessagingTruncated(false) setSessionsLoading(false) }) afterEach(() => { + setCronJobs([]) setSessions([]) setCronSessions([]) setMessagingSessions([]) + setMessagingPlatformTotals({}) + setMessagingTruncated(false) setSessionsLoading(false) }) @@ -262,8 +292,107 @@ describe('refreshSessions batches slices into one request', () => { ) }) - it('scopes the cron-jobs fetch to the active profile (all → unified view)', async () => { - const { getCronJobs } = await import('@/hermes') + it('does not start a refresh callback captured before a profile switch', async () => { + listSidebarSessions.mockResolvedValue(sidebar({ sessions: [] })) + + const { rerender, result } = renderHook(({ profileScope }) => useSessionListActions({ profileScope }), { + initialProps: { profileScope: 'work' } + }) + + const staleRefresh = result.current.refreshSessions + + rerender({ profileScope: 'personal' }) + + await act(async () => { + await staleRefresh() + }) + + 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() + + listSidebarSessions.mockImplementation(({ recentsProfile }) => + recentsProfile === 'work' ? work.promise : personal.promise + ) + + const { rerender, result } = renderHook(({ profileScope }) => useSessionListActions({ profileScope }), { + initialProps: { profileScope: 'work' } + }) + + const workRefresh = result.current.refreshSessions() + + rerender({ profileScope: 'personal' }) + const personalRefresh = result.current.refreshSessions() + + await act(async () => { + personal.resolve( + sidebar( + { sessions: [row('personal-session', { profile: 'personal' })] }, + [row('personal-cron', { profile: 'personal', source: 'cron' })], + [row('personal-signal', { profile: 'personal', source: 'signal' })] + ) + ) + await personalRefresh + + work.resolve( + sidebar( + { sessions: [row('work-session', { profile: 'work' })] }, + [row('work-cron', { profile: 'work', source: 'cron' })], + [row('work-telegram', { profile: 'work', source: 'telegram' })] + ) + ) + await workRefresh + }) + + expect($sessions.get().map(session => session.id)).toEqual(['personal-session']) + expect($cronSessions.get().map(session => session.id)).toEqual(['personal-cron']) + expect($messagingSessions.get().map(session => session.id)).toEqual(['personal-signal']) + }) + + it('scopes the cron-jobs fetch to the active profile', async () => { listSidebarSessions.mockResolvedValue(sidebar({ sessions: [] })) const scoped = renderHook(() => useSessionListActions({ profileScope: 'work' })) @@ -273,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 () => { @@ -282,4 +413,245 @@ describe('refreshSessions batches slices into one request', () => { expect(getCronJobs).toHaveBeenLastCalledWith('all') }) + + it('ignores an out-of-order cron-jobs response from the previous profile', async () => { + const work = deferred>() + const personal = deferred>() + + getCronJobs.mockImplementation((profile: string) => (profile === 'work' ? work.promise : personal.promise)) + + const { rerender, result } = renderHook(({ profileScope }) => useSessionListActions({ profileScope }), { + initialProps: { profileScope: 'work' } + }) + + const workRefresh = result.current.refreshCronJobs() + + rerender({ profileScope: 'personal' }) + const personalRefresh = result.current.refreshCronJobs() + + await act(async () => { + personal.resolve([{ enabled: true, id: 'personal-job' }]) + await personalRefresh + + work.resolve([{ enabled: true, id: 'work-job' }]) + await workRefresh + }) + + expect(getCronJobs.mock.calls.map(call => call[0])).toEqual(['work', 'personal']) + expect($cronJobs.get().map(job => job.id)).toEqual(['personal-job']) + }) +}) + +describe('messaging profile scope', () => { + it('refreshes messaging sessions only for the active profile', async () => { + listAllProfileSessions.mockResolvedValue({ + sessions: [row('m1', { profile: 'work', source: 'signal' })], + total: 1 + }) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'work' })) + + await act(async () => { + await result.current.refreshMessagingSessions() + }) + + expect(listAllProfileSessions).toHaveBeenCalledWith( + expect.any(Number), + 1, + 'exclude', + 'recent', + 'work', + expect.objectContaining({ excludeSources: expect.any(Array) }) + ) + expect($messagingSessions.get().map(s => s.id)).toEqual(['m1']) + }) + + it('keeps the explicit all-profiles view unified', async () => { + listAllProfileSessions.mockResolvedValue({ sessions: [], total: 0 }) + const { result } = renderHook(() => useSessionListActions({ profileScope: '__all__' })) + + await act(async () => { + await result.current.refreshMessagingSessions() + }) + + expect(listAllProfileSessions.mock.calls[0][4]).toBe('all') + }) + + it('keeps per-platform pagination on the active profile', async () => { + setMessagingSessions([row('m1', { profile: 'work', source: 'signal' })]) + listAllProfileSessions.mockResolvedValue({ + sessions: [row('m1', { profile: 'work', source: 'signal' }), row('m2', { profile: 'work', source: 'signal' })], + total: 2 + }) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'work' })) + + await act(async () => { + await result.current.loadMoreMessagingForPlatform('signal') + }) + + expect(listAllProfileSessions.mock.calls[0][4]).toBe('work') + expect($messagingSessions.get().map(s => s.id)).toEqual(['m1', 'm2']) + 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' })], + total: 42 + }) + + const { rerender, result } = renderHook(({ profileScope }) => useSessionListActions({ profileScope }), { + initialProps: { profileScope: 'work' } + }) + + await act(async () => { + await result.current.loadMoreMessagingForPlatform('signal') + }) + + expect($messagingPlatformTotals.get()).toEqual({ 'work:signal': 42 }) + + rerender({ profileScope: 'personal' }) + + expect($messagingPlatformTotals.get()['personal:signal']).toBeUndefined() + expect($messagingPlatformTotals.get()['work:signal']).toBe(42) + + listAllProfileSessions.mockResolvedValue({ + sessions: [row('personal-signal', { profile: 'personal', source: 'signal' })], + total: 3 + }) + + await act(async () => { + await result.current.loadMoreMessagingForPlatform('signal') + }) + + expect($messagingPlatformTotals.get()).toEqual({ 'personal:signal': 3, 'work:signal': 42 }) + + rerender({ profileScope: 'work' }) + + expect($messagingPlatformTotals.get()['work:signal']).toBe(42) + }) + + it('ignores an older overlapping load-more response for the same profile and platform', async () => { + const older = deferred<{ sessions: SessionInfo[]; total: number }>() + const newer = deferred<{ sessions: SessionInfo[]; total: number }>() + + setMessagingSessions([row('m1', { profile: 'work', source: 'signal' })]) + listAllProfileSessions.mockImplementationOnce(() => older.promise).mockImplementationOnce(() => newer.promise) + + const { result } = renderHook(() => useSessionListActions({ profileScope: 'work' })) + const olderLoad = result.current.loadMoreMessagingForPlatform('signal') + + setMessagingSessions([ + row('m1', { profile: 'work', source: 'signal' }), + row('m2', { profile: 'work', source: 'signal' }) + ]) + const newerLoad = result.current.loadMoreMessagingForPlatform('signal') + + await act(async () => { + newer.resolve({ + sessions: [ + row('m1', { profile: 'work', source: 'signal' }), + row('m2', { profile: 'work', source: 'signal' }), + row('m3', { profile: 'work', source: 'signal' }) + ], + total: 3 + }) + await newerLoad + + older.resolve({ + sessions: [row('m1', { profile: 'work', source: 'signal' }), row('m2', { profile: 'work', source: 'signal' })], + total: 2 + }) + await olderLoad + }) + + expect($messagingSessions.get().map(session => session.id)).toEqual(['m1', 'm2', 'm3']) + expect($messagingPlatformTotals.get()).toEqual({ 'work:signal': 3 }) + }) + + it('ignores an in-flight response after the active profile changes', async () => { + const work = deferred<{ sessions: SessionInfo[]; total: number }>() + const personal = deferred<{ sessions: SessionInfo[]; total: number }>() + + listAllProfileSessions.mockImplementation((_limit, _min, _archived, _order, profile) => + profile === 'work' ? work.promise : personal.promise + ) + + const { rerender, result } = renderHook(({ profileScope }) => useSessionListActions({ profileScope }), { + initialProps: { profileScope: 'work' } + }) + + const workRefresh = result.current.refreshMessagingSessions() + rerender({ profileScope: 'personal' }) + const personalRefresh = result.current.refreshMessagingSessions() + + await act(async () => { + personal.resolve({ + sessions: [row('personal-message', { profile: 'personal', source: 'telegram' })], + total: 1 + }) + await personalRefresh + work.resolve({ sessions: [row('work-message', { profile: 'work', source: 'signal' })], total: 1 }) + await workRefresh + }) + + expect(listAllProfileSessions.mock.calls.map(call => call[4])).toEqual(['work', 'personal']) + expect($messagingSessions.get().map(session => session.id)).toEqual(['personal-message']) + }) + + it('does not let a callback captured before a profile switch disturb current totals', async () => { + listAllProfileSessions.mockResolvedValue({ sessions: [], total: 0 }) + setMessagingPlatformTotals({ 'work:signal': 12 }) + + const { rerender, result } = renderHook(({ profileScope }) => useSessionListActions({ profileScope }), { + initialProps: { profileScope: 'work' } + }) + + const staleRefresh = result.current.refreshMessagingSessions + + rerender({ profileScope: 'personal' }) + + await act(async () => { + await staleRefresh() + }) + + expect(listAllProfileSessions).not.toHaveBeenCalled() + expect($messagingPlatformTotals.get()).toEqual({ 'work:signal': 12 }) + }) }) 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 959edfc5a15b..a3416032f2ab 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' @@ -17,7 +17,7 @@ import { SIDEBAR_FILTERED_PAGE_SIZE, SIDEBAR_SESSIONS_PAGE_SIZE } from '@/store/layout' -import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile' +import { messagingTotalsKey, normalizeProfileKey, sidebarProfileForScope } from '@/store/profile' import { $removedSessionIds } from '@/store/projects' import { $messagingSessions, @@ -100,18 +100,40 @@ interface UseSessionListActionsArgs { * and the per-platform messaging slices. Returns the callbacks the controller * wires into the sidebar and refresh effects. */ export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) { + const profileScopeRef = useRef(profileScope) + const loadMoreMessagingRequestRef = useRef>({}) + const refreshMessagingSessionsRequestRef = useRef(0) const refreshSessionsRequestRef = useRef(0) - // Messaging-platform sessions as their own slice, fetched separately from - // local recents so each platform renders a self-managed section and never - // competes with local chats for the recents page budget. One combined fetch - // seeds every platform; the sidebar splits the rows per source. + useLayoutEffect(() => { + profileScopeRef.current = profileScope + }, [profileScope]) + + /** Refresh the active profile's messaging-platform sidebar slice. */ const refreshMessagingSessions = useCallback(async () => { + const sessionProfile = sidebarProfileForScope(profileScope) + + // A callback captured before a profile switch may still be queued by an + // event subscription. Do not let it start a request against the old scope. + if (sidebarProfileForScope(profileScopeRef.current) !== sessionProfile) { + return + } + + const requestId = refreshMessagingSessionsRequestRef.current + 1 + refreshMessagingSessionsRequestRef.current = requestId + try { - const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { + const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', sessionProfile, { excludeSources: MESSAGING_EXCLUDED_SOURCES }) + if ( + refreshMessagingSessionsRequestRef.current !== requestId || + sidebarProfileForScope(profileScopeRef.current) !== sessionProfile + ) { + return + } + // Drop any non-messaging source the broad exclude didn't catch (custom // sources) — those stay in local recents, not a platform section. const rows = dropTombstoned(result.sessions.filter(s => isMessagingSource(s.source))) @@ -123,46 +145,87 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg } catch { // Non-fatal: the messaging sections just stay empty/stale. } - }, []) - - // Page a single platform's section independently (mirrors the per-profile - // pager): fetch that source's next window and merge it back in place, leaving - // every other platform's rows untouched. Resolves the platform's exact total. - const loadMoreMessagingForPlatform = useCallback(async (platform: string) => { - const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform - const loaded = $messagingSessions.get().filter(inPlatform).length - - const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', { - source: platform - }) - - const incoming = dropTombstoned(result.sessions.filter(s => normalizeSessionSource(s.source) === platform)) - - setMessagingSessions(prev => [ - ...prev.filter(s => !inPlatform(s)), - ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep()) - ]) - - const total = result.total ?? incoming.length - setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) })) - }, []) - - // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created - // synchronously (agent tool call or the cron UI), so refreshing here right - // after an agent turn surfaces a new job immediately; the interval poll keeps - // next-run/state fresh as the scheduler advances them. Jobs live per-profile - // on disk and the list endpoint aggregates 'all' by default, so scope the - // fetch to the sidebar's profile scope — a concrete profile sees only its - // own jobs; ALL_PROFILES keeps the unified view. + }, [profileScope]) + + /** Page one messaging platform without replacing another platform's rows. */ + const loadMoreMessagingForPlatform = useCallback( + async (platform: string) => { + const sessionProfile = sidebarProfileForScope(profileScope) + + if (sidebarProfileForScope(profileScopeRef.current) !== sessionProfile) { + return + } + + const requestKey = messagingTotalsKey(sessionProfile, platform) + const requestId = (loadMoreMessagingRequestRef.current[requestKey] ?? 0) + 1 + loadMoreMessagingRequestRef.current[requestKey] = requestId + + const inProfile = (s: SessionInfo) => + sessionProfile === 'all' || normalizeProfileKey(s.profile) === sessionProfile + + const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform && inProfile(s) + const loaded = $messagingSessions.get().filter(inPlatform).length + + 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 || + sidebarProfileForScope(profileScopeRef.current) !== sessionProfile + ) { + return + } + + const incoming = dropTombstoned(result.sessions.filter(inPlatform)) + + setMessagingSessions(prev => [ + ...prev.filter(s => !inPlatform(s)), + ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep()) + ]) + + const total = result.total ?? incoming.length + + setMessagingPlatformTotals(prev => ({ ...prev, [requestKey]: Math.max(total, incoming.length) })) + }, + [profileScope] + ) + + /** Refresh cron jobs only while the profile that requested them remains active. */ const refreshCronJobs = useCallback(async () => { + const sessionProfile = sidebarProfileForScope(profileScope) + + if (sidebarProfileForScope(profileScopeRef.current) !== sessionProfile) { + return + } + try { - await refreshCronJobsStore(profileScope === ALL_PROFILES ? 'all' : profileScope) + await refreshCronJobsStore(sessionProfile) } catch { // Non-fatal: the cron section just keeps its last-known jobs. } }, [profileScope]) + /** Refresh every sidebar session slice without committing an obsolete profile response. */ const refreshSessions = useCallback(async () => { + const sessionProfile = sidebarProfileForScope(profileScope) + + if (sidebarProfileForScope(profileScopeRef.current) !== sessionProfile) { + return + } + const requestId = refreshSessionsRequestRef.current + 1 refreshSessionsRequestRef.current = requestId // The loading flag exists to drive the initial skeletons (they only render @@ -184,12 +247,10 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg // Unified cross-profile list (served read-only off each profile's // state.db; no per-profile backend is spawned). Single-profile users get // the same rows tagged profile="default". - // Scope recents to the active profile (not always 'all') so a profile + // Scope every sidebar slice to the active profile (not always 'all') so a profile // with few recent sessions isn't windowed out of the cross-profile - // recency page — the empty-history-on-profile-switch bug. Cron + messaging - // stay cross-profile. - const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope - + // recency page and never inherits another profile's cron or messaging + // sections. ALL_PROFILES remains the explicit unified view. // Batched: one request opens each profile DB once and returns all three // source-scoped slices, instead of three separate listAllProfileSessions // calls that each reopened + re-counted every profile DB per refresh. @@ -202,7 +263,10 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg messagingExclude: MESSAGING_EXCLUDED_SOURCES }) - if (refreshSessionsRequestRef.current === requestId) { + if ( + refreshSessionsRequestRef.current === requestId && + sidebarProfileForScope(profileScopeRef.current) === sessionProfile + ) { const recents = result.recents // Drop rows the user just deleted/archived: a refresh can race an @@ -267,7 +331,9 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg } // Cron *jobs* are a distinct API (getCronJobs), not a session slice. - void refreshCronJobs() + if (sidebarProfileForScope(profileScopeRef.current) === sessionProfile) { + void refreshCronJobs() + } }, [profileScope, refreshCronJobs]) const loadMoreSessions = useCallback(async () => { diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index de02dacc4738..253a50c4e2bc 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -152,7 +152,7 @@ describe('Hermes REST helpers', () => { }) // Slices reassembled from the legacy per-slice route with the same - // scoping: recents on the caller's profile, cron + messaging cross-profile. + // scoping: every section follows the caller's profile. expect(result.recents.sessions.map(s => s.id)).toEqual(['recent-1']) // One row back against a 30-row window: the profile is fully loaded, so // the legacy path must not claim there's another page. @@ -163,7 +163,10 @@ describe('Hermes REST helpers', () => { const paths = api.mock.calls.map(call => (call[0] as { path: string }).path) expect(paths.filter(p => p.startsWith('/api/profiles/sessions/sidebar'))).toHaveLength(1) expect(paths.filter(p => p.startsWith('/api/profiles/sessions?'))).toHaveLength(3) - expect(paths).toContainEqual(expect.stringContaining('profile=work')) + expect( + paths.filter(path => path.startsWith('/api/profiles/sessions?') && path.includes('profile=work')) + ).toHaveLength(3) + expect(paths.some(path => path.includes('profile=all'))).toBe(false) expect(paths).toContainEqual(expect.stringContaining('source=cron')) expect(paths).toContainEqual(expect.stringContaining('exclude_sources=cron%2Ctool')) }) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 30e1b9912fbd..aca52cae1937 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -542,16 +542,16 @@ function isEndpointMissingError(err: unknown): boolean { // Compatibility fallback: reassemble the three sidebar slices from the // per-slice endpoint, mirroring the batched route's semantics (min_messages=1, -// archived excluded, recency order; recents scoped to the caller's profile, -// cron + messaging cross-profile). Rides the same Electron remote-splice +// archived excluded, recency order; every slice scoped to the caller's profile). +// Rides the same Electron remote-splice // interception as the pre-batching desktop, so remote profiles stay correct. async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise { const [recents, cron, messaging] = await Promise.all([ listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, { excludeSources: req.recentsExclude }), - listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', 'all', { source: 'cron' }), - listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', 'all', { + listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', req.recentsProfile, { source: 'cron' }), + listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', req.recentsProfile, { excludeSources: req.messagingExclude }) ]) diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 0d38269479f1..3d8cf0a630a7 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -315,6 +315,14 @@ export async function ensureGatewayProfile(profile: string | null | undefined): export const ALL_PROFILES = '__all__' +/** Normalize a sidebar scope to the profile key used by session and cron queries. */ +export const sidebarProfileForScope = (profileScope: string): string => + profileScope === ALL_PROFILES ? 'all' : normalizeProfileKey(profileScope) + +/** Key a platform total by its Desktop profile route so counts cannot leak across profiles. */ +export const messagingTotalsKey = (messagingProfile: string, sourceId: string): string => + `${messagingProfile}:${sourceId}` + const SHOW_ALL_PROFILES_STORAGE_KEY = 'hermes.desktop.showAllProfiles' // Opt-in unified view. When false, scope follows the live gateway profile, so