feat(desktop): split cron sessions into their own sidebar section

Scheduler sessions (source=cron) were listed in recents, where their
`[IMPORTANT: …]` first-message previews spammed the list — and because
cron runs are always newest, a burst of them consumed the whole recents
page budget and starved real conversations (sidebar showed 0 sessions).

Recents and cron jobs are now two independent lists:
- Backend: /api/sessions + /api/profiles/sessions accept source /
  exclude_sources; session_count gains exclude_sources. Recents query
  excludes cron; the cron section queries source=cron.
- Desktop: separate $cronSessions store + refreshCronSessions fetch, a
  collapsed (persisted) "Cron jobs" section below Sessions that only
  renders when cron sessions exist, with its own bounded scroller.
This commit is contained in:
Brooklyn Nicholson 2026-06-06 12:30:39 -05:00
parent e3ae035921
commit 628f9040df
12 changed files with 151 additions and 9 deletions

View file

@ -44,6 +44,7 @@ import {
$panesFlipped,
$pinnedSessionIds,
$sidebarAgentsGrouped,
$sidebarCronOpen,
$sidebarOpen,
$sidebarPinsOpen,
$sidebarRecentsOpen,
@ -51,6 +52,7 @@ import {
reorderPinnedSession,
SESSION_SEARCH_FOCUS_EVENT,
setSidebarAgentsGrouped,
setSidebarCronOpen,
setSidebarPinsOpen,
setSidebarRecentsOpen,
SIDEBAR_SESSIONS_PAGE_SIZE,
@ -65,6 +67,7 @@ import {
normalizeProfileKey
} from '@/store/profile'
import {
$cronSessions,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
@ -243,8 +246,10 @@ export function ChatSidebar({
const pinnedSessionIds = useStore($pinnedSessionIds)
const pinsOpen = useStore($sidebarPinsOpen)
const agentsOpen = useStore($sidebarRecentsOpen)
const cronOpen = useStore($sidebarCronOpen)
const selectedSessionId = useStore($selectedStoredSessionId)
const sessions = useStore($sessions)
const cronSessions = useStore($cronSessions)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
@ -323,7 +328,10 @@ export function ChatSidebar({
const sessionByAnyId = useMemo(() => {
const map = new Map<string, SessionInfo>()
for (const s of visibleSessions) {
// Cron sessions are listed separately but can still be pinned, so index
// them too — otherwise a pinned cron job can't resolve into the Pinned
// section. Recents take precedence on id collisions (set last).
for (const s of [...cronSessions, ...visibleSessions]) {
map.set(s.id, s)
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
@ -332,7 +340,7 @@ export function ChatSidebar({
}
return map
}, [visibleSessions])
}, [visibleSessions, cronSessions])
const pinnedSessions = useMemo(() => {
const seen = new Set<string>()
@ -405,6 +413,17 @@ export function ChatSidebar({
return [...out.values()]
}, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId])
// Cron-job sessions are a fully independent list (fetched separately so they
// never consume the recents page budget). Scope them like recents and drop
// any that are pinned (pin wins) to avoid a double-listing.
const visibleCronSessions = useMemo(() => {
const scoped = showAllProfiles
? cronSessions
: cronSessions.filter(s => normalizeProfileKey(s.profile) === profileScope)
return scoped.filter(s => !pinnedRealIdSet.has(s.id)).sort((a, b) => sessionTime(b) - sessionTime(a))
}, [cronSessions, showAllProfiles, profileScope, pinnedRealIdSet])
const unpinnedAgentSessions = useMemo(
() => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)),
[sortedSessions, pinnedRealIdSet]
@ -482,7 +501,10 @@ export function ChatSidebar({
])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
const showSessionSections =
showSessionSkeletons || sortedSessions.length > 0 || visibleCronSessions.length > 0
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
@ -759,6 +781,26 @@ export function ChatSidebar({
/>
)}
{sidebarOpen && showSessionSections && !trimmedQuery && visibleCronSessions.length > 0 && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex max-h-64 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={null}
label={s.cronJobs}
labelMeta={String(visibleCronSessions.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => setSidebarCronOpen(!cronOpen)}
onTogglePin={pinSession}
open={cronOpen}
pinned={false}
rootClassName="shrink-0 p-0 pb-1"
sessions={visibleCronSessions}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
{sidebarOpen && (

View file

@ -41,6 +41,7 @@ import {
sessionPinId,
setAwaitingResponse,
setBusy,
setCronSessions,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
@ -101,6 +102,11 @@ const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).P
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
// Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The
// section shows the most-recent jobs, not the full history (that lives in
// search), so this stays small and is fetched as a single bounded page.
const CRON_SECTION_LIMIT = 50
// Rows a session refresh must preserve even if the aggregator omits them:
// in-flight first turns (message_count 0), pinned rows aged off the page, and
// the actively-viewed chat (its "working" flag clears a beat before the
@ -224,6 +230,21 @@ export function DesktopController() {
}
}, [])
// Cron-job sessions as their own list (latest N). Independent of the recents
// page so the two never compete for slots. Cheap + bounded; refreshed
// alongside recents.
const refreshCronSessions = useCallback(async () => {
try {
const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
source: 'cron'
})
setCronSessions(sessions)
} catch {
// Non-fatal: the cron section just stays empty/stale.
}
}, [])
const refreshSessions = useCallback(async () => {
const requestId = refreshSessionsRequestRef.current + 1
refreshSessionsRequestRef.current = requestId
@ -231,13 +252,18 @@ export function DesktopController() {
try {
const limit = $sessionsLimit.get()
// Require at least one message so abandoned/empty "Untitled" drafts (one
// was created per TUI/desktop launch before the lazy-create fix) don't
// 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".
const result = await listAllProfileSessions(limit, 1)
// 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.
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', 'all', {
excludeSources: ['cron']
})
if (refreshSessionsRequestRef.current === requestId) {
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
@ -249,7 +275,9 @@ export function DesktopController() {
setSessionsLoading(false)
}
}
}, [])
void refreshCronSessions()
}, [refreshCronSessions])
const loadMoreSessions = useCallback(() => {
bumpSessionsLimit()
@ -262,7 +290,11 @@ export function DesktopController() {
const key = normalizeProfileKey(profile)
const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key
const loaded = $sessions.get().filter(inKey).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key)
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
excludeSources: ['cron']
})
const keep = sessionsToKeep(key)
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])

View file

@ -149,17 +149,33 @@ export async function listSessions(
// primary backend straight off each profile's state.db — no per-profile backend
// is spawned. Single-profile users get the same rows as listSessions(), tagged
// profile="default".
// Source scoping lets callers split the unified list into independent slices:
// recents pass `excludeSources: ['cron']`, the cron-jobs section passes
// `source: 'cron'`. Without this a burst of (always-newest) cron sessions
// consumes the whole recents page and starves real conversations.
export interface SessionSourceFilter {
source?: string
excludeSources?: string[]
}
export async function listAllProfileSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude',
order: 'created' | 'recent' = 'recent',
profile: 'all' | (string & {}) = 'all'
profile: 'all' | (string & {}) = 'all',
filter: SessionSourceFilter = {}
): Promise<PaginatedSessions> {
const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : ''
const excludeParam = filter.excludeSources?.length
? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}`
: ''
const result = await window.hermesDesktop.api<PaginatedSessions>({
path:
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}`
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`
})
return {

View file

@ -1044,6 +1044,7 @@ export const en: Translations = {
results: 'Results',
pinned: 'Pinned',
sessions: 'Sessions',
cronJobs: 'Cron jobs',
groupAriaGrouped: 'Show sessions as a single list',
groupAriaUngrouped: 'Group sessions by workspace',
groupTitleGrouped: 'Ungroup sessions',

View file

@ -1147,6 +1147,7 @@ export const ja = defineLocale({
results: '結果',
pinned: 'ピン留め',
sessions: 'セッション',
cronJobs: 'Cronジョブ',
groupAriaGrouped: 'セッションを単一リストとして表示',
groupAriaUngrouped: 'ワークスペースごとにセッションをグループ化',
groupTitleGrouped: 'セッションのグループ化を解除',

View file

@ -801,6 +801,7 @@ export interface Translations {
results: string
pinned: string
sessions: string
cronJobs: string
groupAriaGrouped: string
groupAriaUngrouped: string
groupTitleGrouped: string

View file

@ -1113,6 +1113,7 @@ export const zhHant = defineLocale({
results: '結果',
pinned: '已釘選',
sessions: '工作階段',
cronJobs: '排程任務',
groupAriaGrouped: '以單一清單顯示工作階段',
groupAriaUngrouped: '依工作區分組工作階段',
groupTitleGrouped: '取消分組',

View file

@ -1191,6 +1191,7 @@ export const zh: Translations = {
results: '结果',
pinned: '已置顶',
sessions: '会话',
cronJobs: '定时任务',
groupAriaGrouped: '以单一列表显示会话',
groupAriaUngrouped: '按工作区分组会话',
groupTitleGrouped: '取消分组',

View file

@ -22,6 +22,7 @@ export const SIDEBAR_SESSIONS_PAGE_SIZE = 50
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace'
const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen'
const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
@ -54,6 +55,10 @@ export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states
export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY))
export const $sidebarPinsOpen = atom(true)
export const $sidebarRecentsOpen = atom(true)
// Cron-job sessions live in their own section below recents, collapsed by
// default (it only renders at all when cron sessions exist) so the
// scheduler's `[IMPORTANT: …]` first-message previews don't spam recents.
export const $sidebarCronOpen = atom(storedBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, false))
export const $sidebarAgentsGrouped = atom(storedBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false))
// When true, the sessions sidebar moves to the right and the file browser +
// preview rail move to the left — a mirror of the default layout.
@ -62,6 +67,7 @@ export const $isSidebarResizing = atom(false)
export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE)
$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids]))
$sidebarCronOpen.subscribe(open => persistBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, open))
$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped))
$panesFlipped.subscribe(flipped => persistBoolean(PANES_FLIPPED_STORAGE_KEY, flipped))
@ -114,6 +120,10 @@ export function setSidebarRecentsOpen(open: boolean) {
$sidebarRecentsOpen.set(open)
}
export function setSidebarCronOpen(open: boolean) {
$sidebarCronOpen.set(open)
}
export function setSidebarAgentsGrouped(grouped: boolean) {
$sidebarAgentsGrouped.set(grouped)
}

View file

@ -76,6 +76,10 @@ export const $connection = atom<HermesConnection | null>(null)
export const $gatewayState = atom('idle')
export const $sessions = atom<SessionInfo[]>([])
export const $sessionsTotal = atom<number>(0)
// Cron-job sessions (source === 'cron') are fetched as their own list so the
// scheduler's always-newest sessions never crowd recents out of the page
// budget. Powers the collapsed "Cron jobs" sidebar section.
export const $cronSessions = atom<SessionInfo[]>([])
// Listable conversation count per profile (children excluded), keyed by profile
// name. Lets the sidebar scope its "Load more" footer to the active profile so a
// huge default profile doesn't keep "Load more" visible while browsing a small
@ -119,6 +123,7 @@ export const setConnection = (next: Updater<HermesConnection | null>) => updateA
export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
updateAtom($sessionProfileTotals, next)
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)

View file

@ -1574,6 +1574,8 @@ async def get_sessions(
min_messages: int = 0,
archived: str = "exclude",
order: str = "created",
source: str = None,
exclude_sources: str = None,
):
"""List sessions.
@ -1604,7 +1606,14 @@ async def get_sessions(
min_message_count = max(0, min_messages)
archived_only = archived == "only"
include_archived = archived == "include"
# Optional source scoping: ``source`` includes a single class,
# ``exclude_sources`` (comma-separated) drops classes. The desktop
# uses these to split recents (exclude=cron) from the cron-jobs
# section (source=cron) into two independent lists.
exclude_list = [s for s in (exclude_sources or "").split(",") if s.strip()]
sessions = db.list_sessions_rich(
source=source or None,
exclude_sources=exclude_list or None,
limit=limit,
offset=offset,
min_message_count=min_message_count,
@ -1613,6 +1622,8 @@ async def get_sessions(
order_by_last_active=order == "recent",
)
total = db.session_count(
source=source or None,
exclude_sources=exclude_list or None,
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
@ -1642,6 +1653,8 @@ async def get_profiles_sessions(
archived: str = "exclude",
order: str = "recent",
profile: str = "all",
source: str = None,
exclude_sources: str = None,
):
"""Unified, read-only session list aggregated across ALL profiles.
@ -1677,6 +1690,11 @@ async def get_profiles_sessions(
min_message_count = max(0, min_messages)
archived_only = archived == "only"
include_archived = archived == "include"
# Source scoping (see /api/sessions): recents pass exclude_sources=cron,
# the cron-jobs section passes source=cron — two independent lists so
# newest cron sessions can't starve the recents page.
source_filter = source or None
exclude_list = [s for s in (exclude_sources or "").split(",") if s.strip()]
# Over-fetch per profile so the merged+sorted window is correct for the
# requested page. Capped so a huge profile can't blow up the response.
per_profile = min(max(limit + offset, limit), 500)
@ -1700,6 +1718,8 @@ async def get_profiles_sessions(
continue
try:
rows = db.list_sessions_rich(
source=source_filter,
exclude_sources=exclude_list or None,
limit=per_profile,
offset=0,
min_message_count=min_message_count,
@ -1708,6 +1728,8 @@ async def get_profiles_sessions(
order_by_last_active=order == "recent",
)
profile_total = db.session_count(
source=source_filter,
exclude_sources=exclude_list or None,
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,

View file

@ -3198,6 +3198,7 @@ class SessionDB:
include_archived: bool = False,
archived_only: bool = False,
exclude_children: bool = False,
exclude_sources: List[str] = None,
) -> int:
"""Count sessions, optionally filtered by source.
@ -3207,6 +3208,11 @@ class SessionDB:
is paired with a ``list_sessions_rich`` page (e.g. sidebar "load more"
totals) so the total matches the number of listable rows otherwise the
raw row count is inflated by children and "load more" never settles.
Pass ``exclude_sources`` to drop whole source classes from the count
(e.g. ``["cron"]`` so the recents "load more" total matches a
cron-excluded ``list_sessions_rich`` page and doesn't keep "load more"
stuck on for buried scheduler sessions).
"""
where_clauses = []
params = []
@ -3225,6 +3231,10 @@ class SessionDB:
if source:
where_clauses.append("s.source = ?")
params.append(source)
if exclude_sources:
placeholders = ",".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(exclude_sources)
if min_message_count > 0:
where_clauses.append("s.message_count >= ?")
params.append(min_message_count)