From 628f9040df438182578381a4fc72e4044509d5bd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 12:30:39 -0500 Subject: [PATCH] feat(desktop): split cron sessions into their own sidebar section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/app/chat/sidebar/index.tsx | 48 +++++++++++++++++++-- apps/desktop/src/app/desktop-controller.tsx | 40 +++++++++++++++-- apps/desktop/src/hermes.ts | 20 ++++++++- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/store/layout.ts | 10 +++++ apps/desktop/src/store/session.ts | 5 +++ hermes_cli/web_server.py | 22 ++++++++++ hermes_state.py | 10 +++++ 12 files changed, 151 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index d46948165dc..26e808745a4 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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() - 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() @@ -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 && ( + setSidebarCronOpen(!cronOpen)} + onTogglePin={pinSession} + open={cronOpen} + pinned={false} + rootClassName="shrink-0 p-0 pb-1" + sessions={visibleCronSessions} + workingSessionIdSet={workingSessionIdSet} + /> + )} + {sidebarOpen && !showSessionSections &&
} {sidebarOpen && ( diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index e6ef5cc64f3..48f56b8077e 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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)]) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index c4621ba8d83..20a4c805113 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -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 { + 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({ 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 { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 1f604f3906b..29650b2d5ca 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 3565add8fe3..4b6b120a409 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1147,6 +1147,7 @@ export const ja = defineLocale({ results: '結果', pinned: 'ピン留め', sessions: 'セッション', + cronJobs: 'Cronジョブ', groupAriaGrouped: 'セッションを単一リストとして表示', groupAriaUngrouped: 'ワークスペースごとにセッションをグループ化', groupTitleGrouped: 'セッションのグループ化を解除', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index cc2281c366e..c5495fe4a93 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -801,6 +801,7 @@ export interface Translations { results: string pinned: string sessions: string + cronJobs: string groupAriaGrouped: string groupAriaUngrouped: string groupTitleGrouped: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 09ce699ea09..4051996d24b 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1113,6 +1113,7 @@ export const zhHant = defineLocale({ results: '結果', pinned: '已釘選', sessions: '工作階段', + cronJobs: '排程任務', groupAriaGrouped: '以單一清單顯示工作階段', groupAriaUngrouped: '依工作區分組工作階段', groupTitleGrouped: '取消分組', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 3472352b908..262dc9afa3a 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1191,6 +1191,7 @@ export const zh: Translations = { results: '结果', pinned: '已置顶', sessions: '会话', + cronJobs: '定时任务', groupAriaGrouped: '以单一列表显示会话', groupAriaUngrouped: '按工作区分组会话', groupTitleGrouped: '取消分组', diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index f29605f7715..c01d8b58bd3 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -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 = 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) } diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index d60b22e6bf7..60f669a697c 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -76,6 +76,10 @@ export const $connection = atom(null) export const $gatewayState = atom('idle') export const $sessions = atom([]) export const $sessionsTotal = atom(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([]) // 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) => updateA export const setGatewayState = (next: Updater) => updateAtom($gatewayState, next) export const setSessions = (next: Updater) => updateAtom($sessions, next) export const setSessionsTotal = (next: Updater) => updateAtom($sessionsTotal, next) +export const setCronSessions = (next: Updater) => updateAtom($cronSessions, next) export const setSessionProfileTotals = (next: Updater>) => updateAtom($sessionProfileTotals, next) export const setSessionsLoading = (next: Updater) => updateAtom($sessionsLoading, next) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 95cfd34fc14..8afb820988d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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, diff --git a/hermes_state.py b/hermes_state.py index 5a6aa8e8a62..90247a02a0e 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -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)