perf(desktop): batch sidebar session slices into one profile-DB pass

The sidebar refresh fired three /api/profiles/sessions calls (recents, cron,
messaging), and each one reopened every selected profile's state.db and re-ran
list_sessions_rich + session_count — ~3N DB opens/counts per refresh, on every
turn/broadcast/reconnect.

Add GET /api/profiles/sessions/sidebar: one pass that opens each profile DB
once and runs the three source-scoped queries together (recents scoped to the
active profile; cron + messaging cross-profile), returning the three windows in
one payload. Same read-only projection, 300s active heuristic, and caller-
supplied source taxonomy (recents_exclude / messaging_exclude / source=cron) as
the per-slice endpoint.

Renderer refreshSessions now makes one listSidebarSessions call and distributes
recents/cron/messaging to their stores (cron *jobs* stay a separate getCronJobs
API). Electron splices remote profiles per slice via fetchProfilesSessionSlice
(reusing the proven per-slice merge) so remote correctness is preserved; the
no-remote common case gets the single-open fast path.

From the Desktop performance audit (P1: "Batch sidebar session slices").
This commit is contained in:
Brooklyn Nicholson 2026-07-18 21:45:18 -04:00
parent 614dc194ea
commit 40160e2a04
7 changed files with 476 additions and 48 deletions

View file

@ -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

View file

@ -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> = {}): 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' }))
@ -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'])
})
)
})
})

View file

@ -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()

View file

@ -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: [] })

View file

@ -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<string, number>
}
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<SidebarSessionsResponse> {
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<SidebarSessionsResponse>({
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

View file

@ -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.

View file

@ -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."""