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
89 changes: 89 additions & 0 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8089,6 +8089,71 @@ async function interceptSessionRequestForRemote(request) {
return mergeRemoteProfileSessions(searchParams, remoteProfiles)
}

// Batched sidebar slices. With no remote profiles the local batched endpoint
// (one DB open per profile) serves it directly — take the fast path. When
// remotes exist, fan the three slices back out to the per-slice
// /api/profiles/sessions path (which already merges remote rows correctly) and
// reassemble; local profiles fall back to three primary reads there, but
// remote correctness is preserved.
if (method === 'GET' && pathname === '/api/profiles/sessions/sidebar') {
const remoteProfiles = configuredRemoteProfileNames()

if (remoteProfiles.length === 0) {
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, cron, messaging] = await Promise.all([
fetchProfilesSessionSlice(recentsSp, remoteProfiles),
fetchProfilesSessionSlice(cronSp, remoteProfiles),
fetchProfilesSessionSlice(messagingSp, remoteProfiles)
])

return {
recents: {
sessions: rowsOf(recents),
total: Number(recents?.total) || 0,
profile_totals: recents?.profile_totals || {}
},
cron: { sessions: rowsOf(cron) },
messaging: {
sessions: rowsOf(messaging),
total: Number(messaging?.total) || rowsOf(messaging).length
},
errors: []
}
}

// Per-session read/mutation. Owner is in ?profile= (reads) or request.profile
// (mutations). Two remote shapes:
// - per-profile override: route to that profile's own remote, sans profile
Expand Down Expand Up @@ -8153,6 +8218,30 @@ async function remoteSessionList(profile, searchParams) {
return { ...(data as any), sessions: rowsOf(data) }
}

// Resolve one /api/profiles/sessions slice with remote profiles spliced in —
// the same branch logic as the GET /api/profiles/sessions intercept, but always
// returns data (never `undefined`) so a batched caller can compose slices. A
// specific local profile reads from the local primary; a remote-override profile
// reads from its remote; 'all' merges every remote into the primary aggregate.
async function fetchProfilesSessionSlice(searchParams, remoteProfiles) {
const requested = (searchParams.get('profile') || 'all').trim() || 'all'

if (requested !== 'all') {
if (profileHasRemoteOverride(requested)) {
return remoteSessionList(requested, searchParams)
}

const primary = await ensureBackend(null)

return fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, {
method: 'GET',
timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
}).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))
}

return mergeRemoteProfileSessions(searchParams, remoteProfiles)
}

// Unified list: primary's local aggregate, with each remote profile's stale local
// rows/totals swapped for the remote's real ones, re-sorted by recency and
// re-windowed to the requested page. A dead remote contributes nothing rather
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import type { SessionInfo } from '@/hermes'
import { $sessions, $sessionsLoading, setSessions, setSessionsLoading } from '@/store/session'
import type { SessionInfo, SidebarSessionsResponse } from '@/hermes'
import { $cronSessions, $messagingSessions, $sessions, $sessionsLoading, setCronSessions, setMessagingSessions, setSessions, setSessionsLoading } from '@/store/session'

import { useSessionListActions } from './use-session-list-actions'

Expand All @@ -29,29 +29,50 @@ const row = (id: string, over: Partial<SessionInfo> = {}): SessionInfo =>
...over
}) as SessionInfo

// Batched sidebar response builder. `refreshSessions` now makes ONE
// listSidebarSessions call that returns all three slices, replacing the three
// separate listAllProfileSessions calls (each of which reopened every profile
// DB) — #66377-adjacent perf work from the desktop audit canvas.
const sidebar = (
recents: { sessions: SessionInfo[]; total?: number; profile_totals?: Record<string, number> },
cron: SessionInfo[] = [],
messaging: SessionInfo[] = []
): SidebarSessionsResponse => ({
recents: { sessions: recents.sessions, total: recents.total, profile_totals: recents.profile_totals },
cron: { sessions: cron },
messaging: { sessions: messaging, total: messaging.length }
})

const listSidebarSessions = vi.fn()
const listAllProfileSessions = vi.fn()

vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
getCronJobs: vi.fn(async () => []),
listAllProfileSessions: (...args: unknown[]) => listAllProfileSessions(...args)
listAllProfileSessions: (...args: unknown[]) => listAllProfileSessions(...args),
listSidebarSessions: (...args: unknown[]) => listSidebarSessions(...args)
}))

beforeEach(() => {
listSidebarSessions.mockReset()
listAllProfileSessions.mockReset()
setSessions([])
setCronSessions([])
setMessagingSessions([])
setSessionsLoading(false)
})

afterEach(() => {
setSessions([])
setCronSessions([])
setMessagingSessions([])
setSessionsLoading(false)
})

describe('refreshSessions identity + loading hygiene', () => {
it('keeps the previous $sessions array when the refresh is content-identical', async () => {
const rows = [row('a'), row('b')]
listAllProfileSessions.mockResolvedValue({ sessions: rows, total: 2, profile_totals: { default: 2 } })
listSidebarSessions.mockResolvedValue(sidebar({ sessions: rows, total: 2, profile_totals: { default: 2 } }))

const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))

Expand All @@ -63,11 +84,9 @@ describe('refreshSessions identity + loading hygiene', () => {
expect(first.map(s => s.id)).toEqual(['a', 'b'])

// Second refresh returns fresh (but equal) row objects, as the API does.
listAllProfileSessions.mockResolvedValue({
sessions: [row('a'), row('b')],
total: 2,
profile_totals: { default: 2 }
})
listSidebarSessions.mockResolvedValue(
sidebar({ sessions: [row('a'), row('b')], total: 2, profile_totals: { default: 2 } })
)

await act(async () => {
await result.current.refreshSessions()
Expand All @@ -77,7 +96,7 @@ describe('refreshSessions identity + loading hygiene', () => {
})

it('swaps the array when rows actually changed', async () => {
listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))

await act(async () => {
Expand All @@ -86,11 +105,9 @@ describe('refreshSessions identity + loading hygiene', () => {

const first = $sessions.get()

listAllProfileSessions.mockResolvedValue({
sessions: [row('a', { last_active: 2000, title: 'Renamed' })],
total: 1,
profile_totals: {}
})
listSidebarSessions.mockResolvedValue(
sidebar({ sessions: [row('a', { last_active: 2000, title: 'Renamed' })], total: 1, profile_totals: {} })
)

await act(async () => {
await result.current.refreshSessions()
Expand All @@ -101,7 +118,7 @@ describe('refreshSessions identity + loading hygiene', () => {
})

it('does not flicker the loading flag over a populated list', async () => {
listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))

await act(async () => {
Expand All @@ -121,7 +138,7 @@ describe('refreshSessions identity + loading hygiene', () => {
})

it('still shows loading for the initial (empty-list) fetch', async () => {
listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))

const loadingStates: boolean[] = []
Expand All @@ -135,3 +152,45 @@ describe('refreshSessions identity + loading hygiene', () => {
expect(loadingStates).toEqual([false, true, false])
})
})

describe('refreshSessions batches slices into one request', () => {
it('makes a single sidebar call and distributes recents / cron / messaging', async () => {
const recents = [row('a'), row('b')]
const cron = [row('c1', { source: 'cron', title: 'nightly' })]
const messaging = [row('m1', { source: 'telegram', title: 'tg chat' })]

listSidebarSessions.mockResolvedValue(sidebar({ sessions: recents, total: 2, profile_totals: { default: 2 } }, cron, messaging))

const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))

await act(async () => {
await result.current.refreshSessions()
})

// One batched call, not three separate listAllProfileSessions reads.
expect(listSidebarSessions).toHaveBeenCalledTimes(1)
expect(listAllProfileSessions).not.toHaveBeenCalled()

// Each slice landed in its own store.
expect($sessions.get().map(s => s.id)).toEqual(['a', 'b'])
expect($cronSessions.get().map(s => s.id)).toEqual(['c1'])
expect($messagingSessions.get().map(s => s.id)).toEqual(['m1'])
})

it('forwards the active profile scope + section limits to the batched call', async () => {
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'work' }))

await act(async () => {
await result.current.refreshSessions()
})

expect(listSidebarSessions).toHaveBeenCalledWith(
expect.objectContaining({
recentsProfile: 'work',
recentsExclude: expect.arrayContaining(['cron']),
messagingExclude: expect.arrayContaining(['cron'])
})
)
})
})
65 changes: 35 additions & 30 deletions apps/desktop/src/app/session/hooks/use-session-list-actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useRef } from 'react'

import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes'
import { getCronJobs, listAllProfileSessions, listSidebarSessions, type SessionInfo } from '@/hermes'
import { sameCronSignature } from '@/lib/session-signatures'
import {
isMessagingSource,
Expand Down Expand Up @@ -75,22 +75,6 @@ interface UseSessionListActionsArgs {
export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) {
const refreshSessionsRequestRef = useRef(0)

// Cron-job sessions as their own list (latest N). Independent of the recents
// page so the two never compete for slots. Cheap + bounded. Kept (even though
// the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run
// still resolves into the Pinned section via sessionByAnyId.
const refreshCronSessions = useCallback(async () => {
try {
const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
source: 'cron'
})

setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions))
} catch {
// Non-fatal: the cron section just stays empty/stale.
}
}, [])

// 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
Expand Down Expand Up @@ -171,48 +155,69 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
// clutter the sidebar.
// 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". Cron sessions are excluded here
// and fetched separately (refreshCronSessions) so the scheduler's
// always-newest rows can't consume the recents page budget.
// Scope the fetch to the active profile (not always 'all') so a profile
// the same rows tagged profile="default".
// Scope recents 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.
// recency page — the empty-history-on-profile-switch bug. Cron + messaging
// stay cross-profile.
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope

const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
// 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.
const result = await listSidebarSessions({
recentsProfile: sessionProfile,
recentsLimit: limit,
recentsExclude: SIDEBAR_EXCLUDED_SOURCES,
cronLimit: CRON_SECTION_LIMIT,
messagingLimit: MESSAGING_SECTION_LIMIT,
messagingExclude: MESSAGING_EXCLUDED_SOURCES
})

if (refreshSessionsRequestRef.current === requestId) {
const recents = result.recents

// Signature-gate the swap (same pattern as cron/messaging): a refresh
// that returns content-identical rows must keep the previous array
// identity, or every sidebar memo keyed on $sessions recomputes and the
// whole list re-renders once per turn/broadcast for nothing.
setSessions(prev => {
const next = mergeSessionPage(prev, result.sessions, sessionsToKeep())
const next = mergeSessionPage(prev, recents.sessions, sessionsToKeep())

return sameCronSignature(prev, next) ? prev : next
})
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
setSessionsTotal(typeof recents.total === 'number' ? recents.total : recents.sessions.length)
setSessionProfileTotals(prev => {
const next = result.profile_totals ?? {}
const next = recents.profile_totals ?? {}
const prevKeys = Object.keys(prev)

return prevKeys.length === Object.keys(next).length && prevKeys.every(key => prev[key] === next[key])
? prev
: next
})

// Cron section: latest N cron sessions (kept so a pinned cron run still
// resolves via sessionByAnyId), signature-gated like above.
setCronSessions(prev => (sameCronSignature(prev, result.cron.sessions) ? prev : result.cron.sessions))

// Messaging sections: drop any non-messaging source the broad exclude
// didn't catch (custom sources stay in local recents), then split per
// platform in the UI.
const messagingRows = result.messaging.sessions.filter(s => isMessagingSource(s.source))

setMessagingSessions(prev => (sameCronSignature(prev, messagingRows) ? prev : messagingRows))
// Hit the cap → at least one platform may have more on disk than loaded.
setMessagingTruncated(result.messaging.sessions.length >= MESSAGING_SECTION_LIMIT)
}
} finally {
if (showLoading && refreshSessionsRequestRef.current === requestId) {
setSessionsLoading(false)
}
}

void refreshCronSessions()
// Cron *jobs* are a distinct API (getCronJobs), not a session slice.
void refreshCronJobs()
void refreshMessagingSessions()
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
}, [profileScope, refreshCronJobs])

const loadMoreSessions = useCallback(async () => {
bumpSessionsLimit()
Expand Down
Loading
Loading