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
+45 -3
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 && (
+36 -4
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)])