diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 66c42a941d07..646eefc6eb2d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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 @@ -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 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 908d1d88f214..f4914e081543 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,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' @@ -29,29 +29,50 @@ const row = (id: string, over: Partial = {}): 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 }, + 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>()), 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' })) @@ -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() @@ -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 () => { @@ -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() @@ -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 () => { @@ -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[] = [] @@ -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']) + }) + ) + }) +}) 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 4005233da3f1..0b5e0e219161 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,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, @@ -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 @@ -171,37 +155,59 @@ 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) { @@ -209,10 +215,9 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg } } - 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() diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index 882b5588663d..3de900c18b2f 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -10,7 +10,8 @@ import { getSessionMessages, getStatus, listAllProfileSessions, - listSessions + listSessions, + listSidebarSessions } from './hermes' import { refreshActiveProfile } from './store/profile' @@ -59,6 +60,45 @@ describe('Hermes REST session helpers', () => { ) }) + it('batches the sidebar slices into a single request with per-slice limits + excludes', async () => { + api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } }) + + await listSidebarSessions({ + recentsProfile: 'work', + recentsLimit: 30, + recentsExclude: ['cron', 'tool'], + cronLimit: 50, + messagingLimit: 100, + messagingExclude: ['cron', 'desktop'] + }) + + expect(api).toHaveBeenCalledWith( + expect.objectContaining({ + path: + '/api/profiles/sessions/sidebar?recents_profile=work&recents_limit=30&cron_limit=50' + + '&messaging_limit=100&recents_exclude=cron%2Ctool&messaging_exclude=cron%2Cdesktop', + timeoutMs: 60_000 + }) + ) + }) + + it('defaults missing sidebar slices to empty session arrays', async () => { + api.mockResolvedValue({}) + + const result = await listSidebarSessions({ + recentsProfile: 'all', + recentsLimit: 20, + recentsExclude: [], + cronLimit: 50, + messagingLimit: 100, + messagingExclude: [] + }) + + expect(result.recents.sessions).toEqual([]) + expect(result.cron.sessions).toEqual([]) + expect(result.messaging.sessions).toEqual([]) + }) + it('uses a longer timeout for profile listing during desktop startup', async () => { api.mockResolvedValue({ profiles: [] }) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index e637e0763137..a310eaebafc8 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -359,6 +359,62 @@ export async function listAllProfileSessions( } } +// Batched sidebar slices in one request: recents (scoped to the active profile), +// cron, and messaging. The backend opens each profile's state.db once and runs +// all three filtered queries, replacing three separate listAllProfileSessions +// calls that each reopened + re-counted every profile DB per refresh. Electron +// splices remote profiles per slice (see interceptSessionRequestForRemote). +export interface SidebarSessionSlice { + sessions: SessionInfo[] + total?: number + profile_totals?: Record +} + +export interface SidebarSessionsResponse { + recents: SidebarSessionSlice + cron: SidebarSessionSlice + messaging: SidebarSessionSlice + errors?: Array<{ profile: string; error: string }> +} + +export interface SidebarSessionsRequest { + recentsProfile: 'all' | (string & {}) + recentsLimit: number + recentsExclude: string[] + cronLimit: number + messagingLimit: number + messagingExclude: string[] +} + +export async function listSidebarSessions(req: SidebarSessionsRequest): Promise { + const params = new URLSearchParams({ + recents_profile: req.recentsProfile, + recents_limit: String(Math.max(1, req.recentsLimit)), + cron_limit: String(Math.max(1, req.cronLimit)), + messaging_limit: String(Math.max(1, req.messagingLimit)) + }) + + if (req.recentsExclude.length) { + params.set('recents_exclude', req.recentsExclude.join(',')) + } + + if (req.messagingExclude.length) { + params.set('messaging_exclude', req.messagingExclude.join(',')) + } + + const result = await window.hermesDesktop.api({ + path: `/api/profiles/sessions/sidebar?${params.toString()}`, + timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS + }) + + return { + recents: { ...result.recents, sessions: result.recents?.sessions ?? [] }, + cron: { ...result.cron, sessions: result.cron?.sessions ?? [] }, + messaging: { ...result.messaging, sessions: result.messaging?.sessions ?? [] }, + errors: result.errors + } +} + // Mutations take the owning `profile` so Electron routes them to that profile's // backend (remote pool or local primary) via request.profile — matching the // read path. A remote session's row lives only on its remote host, so a mutation diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index dc4914a06f80..1962fcf4f6ed 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4266,6 +4266,139 @@ def get_profiles_sessions( } +@app.get("/api/profiles/sessions/sidebar") +def get_profiles_sessions_sidebar( + recents_profile: str = "all", + recents_limit: int = 20, + recents_exclude: str = None, + cron_limit: int = 50, + messaging_limit: int = 100, + messaging_exclude: str = None, +): + """Batched sidebar session slices — one profile-DB open per refresh. + + The desktop sidebar needs three source-scoped windows per refresh: recents + (local chats, scoped to the active profile), cron sessions (all profiles), + and messaging-platform sessions (all profiles). Served as three separate + ``/api/profiles/sessions`` calls they reopened every profile's ``state.db`` + three times and re-counted each refresh. This opens each DB once and runs + the three filtered queries together, returning the three windows in one + payload. Read-only and process-light, same row projection and 300s active + heuristic as ``/api/profiles/sessions``. + + The caller passes the source taxonomy (``recents_exclude`` / + ``messaging_exclude`` CSV, ``source=cron`` is implicit) so this stays + taxonomy-agnostic like the per-slice endpoint. All three slices use + ``min_messages=1`` / ``archived=exclude`` / recency order, matching the + desktop's per-slice calls. + """ + from hermes_state import SessionDB + from hermes_cli import profiles as profiles_mod + + # cron + messaging are cross-profile; recents is scoped to recents_profile. + # Scan every profile once regardless (each DB opened a single time). + try: + infos = profiles_mod.list_profiles() + targets: List[Tuple[str, Path]] = [(info.name, info.path) for info in infos] + except Exception: + _log.exception("GET /api/profiles/sessions/sidebar: list_profiles failed") + targets = [] + if not targets: + targets.append(("default", profiles_mod.get_profile_dir("default"))) + + recents_scope = (recents_profile or "all").strip() or "all" + recents_exclude_list = [s for s in (recents_exclude or "").split(",") if s.strip()] + messaging_exclude_list = [s for s in (messaging_exclude or "").split(",") if s.strip()] + + recents_cap = min(max(recents_limit, 1), 500) + cron_cap = min(max(cron_limit, 1), 500) + messaging_cap = min(max(messaging_limit, 1), 500) + + recents_rows: List[Dict[str, Any]] = [] + cron_rows: List[Dict[str, Any]] = [] + messaging_rows: List[Dict[str, Any]] = [] + recents_total = 0 + recents_profile_totals: Dict[str, int] = {} + errors: List[Dict[str, str]] = [] + now = time.time() + + def _tag(rows: List[Dict[str, Any]], name: str) -> List[Dict[str, Any]]: + for s in rows: + s["profile"] = name + s["is_default_profile"] = name == "default" + s["is_active"] = ( + s.get("ended_at") is None + and (now - s.get("last_active", s.get("started_at", 0))) < 300 + ) + s["archived"] = bool(s.get("archived")) + return rows + + def _slice(db, *, source=None, exclude=None, cap): + return db.list_sessions_rich( + source=source, + exclude_sources=exclude or None, + limit=cap, + offset=0, + min_message_count=1, + include_archived=False, + archived_only=False, + order_by_last_active=True, + compact_rows=True, + ) + + for name, home in targets: + db_path = Path(home) / "state.db" + if not db_path.exists(): + continue + try: + db = SessionDB(db_path=db_path, read_only=True) + except Exception as exc: + errors.append({"profile": name, "error": str(exc)}) + continue + try: + if recents_scope == "all" or name == recents_scope: + recents_rows.extend( + _tag(_slice(db, exclude=recents_exclude_list, cap=recents_cap), name) + ) + rtotal = db.session_count( + exclude_sources=recents_exclude_list or None, + min_message_count=1, + include_archived=False, + archived_only=False, + exclude_children=True, + ) + recents_total += rtotal + recents_profile_totals[name] = rtotal + cron_rows.extend(_tag(_slice(db, source="cron", cap=cron_cap), name)) + messaging_rows.extend( + _tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name) + ) + except Exception as exc: + errors.append({"profile": name, "error": str(exc)}) + finally: + db.close() + + def _window(rows: List[Dict[str, Any]], cap: int) -> List[Dict[str, Any]]: + rows.sort(key=lambda s: s.get("last_active") or s.get("started_at") or 0, reverse=True) + win = rows[:cap] + _strip_session_list_rows(win) + return win + + return { + "recents": { + "sessions": _window(recents_rows, recents_cap), + "total": recents_total, + "profile_totals": recents_profile_totals, + }, + "cron": {"sessions": _window(cron_rows, cron_cap)}, + "messaging": { + "sessions": _window(messaging_rows, messaging_cap), + "total": len(messaging_rows), + }, + "errors": errors, + } + + @app.get("/api/sessions/search") async def search_sessions(q: str = "", limit: int = 20, profile: Optional[str] = None): """Search sessions by ID plus full-text message content using FTS5. diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 3f370f14ecf1..d403dfb436a4 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1473,6 +1473,52 @@ class TestWebServerEndpoints: resp = self.client.get("/api/profiles/sessions?archived=bogus") assert resp.status_code == 400 + def test_profiles_sessions_sidebar_batches_three_slices(self): + """The batched sidebar endpoint returns recents/cron/messaging in one + pass, each source-scoped by the caller-supplied excludes, so the desktop + stops reopening every profile DB three times per refresh.""" + from hermes_state import SessionDB + + db = SessionDB() + try: + for sid, src in ( + ("sb-desktop", "desktop"), + ("sb-cron", "cron"), + ("sb-telegram", "telegram"), + ): + db.create_session(session_id=sid, source=src) + db.append_message(session_id=sid, role="user", content="hi") + finally: + db.close() + + resp = self.client.get( + "/api/profiles/sessions/sidebar" + "?recents_profile=all&recents_limit=20&recents_exclude=cron,telegram" + "&cron_limit=50&messaging_limit=100" + "&messaging_exclude=cron,cli,codex,desktop,gateway,local,tui" + ) + assert resp.status_code == 200 + data = resp.json() + + recents_ids = {s["id"] for s in data["recents"]["sessions"]} + cron_ids = {s["id"] for s in data["cron"]["sessions"]} + messaging_ids = {s["id"] for s in data["messaging"]["sessions"]} + + # Each session lands only in its own slice. + assert "sb-desktop" in recents_ids + assert "sb-desktop" not in cron_ids and "sb-desktop" not in messaging_ids + assert "sb-cron" in cron_ids + assert "sb-cron" not in recents_ids and "sb-cron" not in messaging_ids + assert "sb-telegram" in messaging_ids + assert "sb-telegram" not in recents_ids and "sb-telegram" not in cron_ids + + # Rows carry profile tagging like /api/profiles/sessions. + row = next(s for s in data["recents"]["sessions"] if s["id"] == "sb-desktop") + assert row["profile"] == "default" + assert row["is_default_profile"] is True + assert isinstance(data.get("errors"), list) + assert data["recents"]["total"] >= 1 + def test_sessions_endpoint_reads_requested_profile(self): """The machine dashboard's global profile switcher must retarget the Sessions page, not just config/skills/model pages."""