Merge pull request #40684 from NousResearch/bb/cron-sessions-sidebar

feat(desktop): first-class cron jobs in the sidebar + dashboard scheduler
This commit is contained in:
brooklyn! 2026-06-07 00:32:25 -05:00 committed by GitHub
commit 846821d8c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1219 additions and 354 deletions

View file

@ -4369,6 +4369,9 @@ async function spawnPoolBackend(profile, entry) {
HERMES_HOME,
...backend.env,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
},
shell: backend.shell,
@ -4510,6 +4513,9 @@ async function startHermes() {
HERMES_HOME,
...backend.env,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
},
shell: backend.shell,

View file

@ -0,0 +1,325 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
import { Tip } from '@/components/ui/tooltip'
import { getCronJobRuns, type SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $selectedStoredSessionId } from '@/store/session'
import type { CronJob } from '@/types/hermes'
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
// Recent runs shown in the inline quick-peek — enough to glance at history
// without turning the sidebar into the full Cron page.
const PEEK_RUN_LIMIT = 5
// Runs are written by the background scheduler tick (no UI signal), so poll the
// open peek so a freshly-fired run shows up within a few seconds.
const PEEK_POLL_INTERVAL_MS = 8000
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
// coarsest sensible unit so a daily job reads "in 14 hr", not "in 840 min".
function relativeTime(targetMs: number, nowMs: number): string {
const diff = targetMs - nowMs
const abs = Math.abs(diff)
const sign = diff < 0 ? -1 : 1
if (abs < 60_000) {return relativeFmt.format(sign * Math.round(abs / 1000), 'second')}
if (abs < 3_600_000) {return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')}
if (abs < 86_400_000) {return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')}
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
}
function nextRunMs(job: CronJob): null | number {
if (!job.next_run_at) {return null}
const ms = Date.parse(job.next_run_at)
return Number.isNaN(ms) ? null : ms
}
// Runs all belong to the same job, so the run name just repeats the job name —
// the timestamp is what tells them apart. Compact (no year, no seconds) for the
// narrow sidebar.
function formatRunTime(seconds?: null | number): string {
if (!seconds) {return '—'}
const date = new Date(seconds * 1000)
return Number.isNaN(date.valueOf())
? '—'
: date.toLocaleString(undefined, { day: 'numeric', hour: 'numeric', minute: '2-digit', month: 'short' })
}
interface SidebarCronJobsSectionProps {
jobs: CronJob[]
label: string
max?: number
// Open a run session's chat (1 click to output).
onOpenRun: (sessionId: string) => void
// Open the full Cron page focused on this job (manage / full history).
onManageJob: (jobId: string) => void
// Fire the job now.
onTriggerJob: (jobId: string) => void
onToggle: () => void
open: boolean
}
export function SidebarCronJobsSection({
jobs,
label,
max = 50,
onManageJob,
onOpenRun,
onTriggerJob,
onToggle,
open
}: SidebarCronJobsSectionProps) {
const [nowMs, setNowMs] = useState(() => Date.now())
// Single-open inline peek so the section stays scannable.
const [peekJobId, setPeekJobId] = useState<null | string>(null)
// One clock for the whole section (rows are pure) so the countdowns tick
// without re-rendering the rest of the sidebar. Only runs while expanded.
useEffect(() => {
if (!open) {return}
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
return () => window.clearInterval(id)
}, [open])
// Upcoming first (soonest next run), jobs with no next run sink to the bottom,
// then alphabetical for stability.
const sorted = useMemo(() => {
return [...jobs].sort((a, b) => {
const an = nextRunMs(a)
const bn = nextRunMs(b)
if (an !== null && bn !== null && an !== bn) {return an - bn}
if (an === null && bn !== null) {return 1}
if (an !== null && bn === null) {return -1}
return jobTitle(a).localeCompare(jobTitle(b))
})
}, [jobs])
const shown = sorted.slice(0, max)
// When capped, signal "50+" rather than implying the list is complete.
const countLabel = jobs.length > max ? `${max}+` : String(jobs.length)
return (
<SidebarGroup className="shrink-0 p-0 pb-1">
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
onClick={onToggle}
type="button"
>
<SidebarPanelLabel>{label}</SidebarPanelLabel>
<span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{countLabel}</span>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/section-label:opacity-100"
open={open}
/>
</button>
</div>
{open && (
<SidebarGroupContent className="flex max-h-72 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
{shown.map(job => (
<CronJobSidebarRow
expanded={peekJobId === job.id}
job={job}
key={job.id}
nowMs={nowMs}
onManage={() => onManageJob(job.id)}
onOpenRun={onOpenRun}
onTogglePeek={() => setPeekJobId(prev => (prev === job.id ? null : job.id))}
onTrigger={() => onTriggerJob(job.id)}
/>
))}
</SidebarGroupContent>
)}
</SidebarGroup>
)
}
function CronJobSidebarRow({
expanded,
job,
nowMs,
onManage,
onOpenRun,
onTogglePeek,
onTrigger
}: {
expanded: boolean
job: CronJob
nowMs: number
onManage: () => void
onOpenRun: (sessionId: string) => void
onTogglePeek: () => void
onTrigger: () => void
}) {
const { t } = useI18n()
const c = t.cron
const state = jobState(job)
const next = nextRunMs(job)
const label = jobTitle(job)
const meta = INACTIVE_STATES.has(state)
? (c.states[state] ?? state)
: next !== null
? relativeTime(next, nowMs)
: '—'
return (
<div>
<div className="group/cron relative grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_auto] items-center rounded-md hover:bg-(--chrome-action-hover)">
{/* Lead with the dot in the same w-3.5 cell + pl-2 the session rows use
so the cron dots line up with the sessions above; the caret sits next
to the label (matching the other sidebar disclosures) and the whole
label area toggles the run peek. */}
<button
aria-expanded={expanded}
aria-label={expanded ? c.hideRuns : c.showRuns}
className="flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={onTogglePeek}
title={label}
type="button"
>
<span className="grid w-3.5 shrink-0 place-items-center">
<span
aria-hidden="true"
className={cn(
'size-1 rounded-full',
STATE_DOT[state] ?? 'bg-(--ui-text-quaternary)',
state === 'running' && 'size-1.5 animate-pulse'
)}
/>
</span>
<span className="min-w-0 truncate text-[0.8125rem] text-(--ui-text-secondary) group-hover/cron:text-foreground">
{label}
</span>
<DisclosureCaret
className={cn(
'shrink-0 text-(--ui-text-tertiary) transition',
expanded ? 'opacity-100' : 'opacity-0 group-hover/cron:opacity-100'
)}
open={expanded}
/>
</button>
{/* Trailing cluster: countdown by default, quick actions on hover. */}
<div className="flex items-center gap-0.5 justify-self-end pr-1">
<span className="text-[0.6875rem] text-(--ui-text-tertiary) tabular-nums group-hover/cron:hidden">
{meta}
</span>
<div className="hidden items-center gap-0.5 group-hover/cron:flex">
<Tip label={c.triggerNow}>
<button
aria-label={c.triggerNow}
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onTrigger}
type="button"
>
<Codicon name="zap" size="0.75rem" />
</button>
</Tip>
<Tip label={c.manage}>
<button
aria-label={c.manage}
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onManage}
type="button"
>
<Codicon name="watch" size="0.75rem" />
</button>
</Tip>
</div>
</div>
</div>
{expanded && <CronJobSidebarRuns jobId={job.id} onOpenRun={onOpenRun} />}
</div>
)
}
function CronJobSidebarRuns({
jobId,
onOpenRun
}: {
jobId: string
onOpenRun: (sessionId: string) => void
}) {
const { t } = useI18n()
const c = t.cron
const selectedSessionId = useStore($selectedStoredSessionId)
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
useEffect(() => {
let cancelled = false
const load = () =>
getCronJobRuns(jobId, PEEK_RUN_LIMIT)
.then(result => {
if (!cancelled) {setRuns(result)}
})
.catch(() => {
if (!cancelled) {setRuns(prev => prev ?? [])}
})
void load()
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') {void load()}
}, PEEK_POLL_INTERVAL_MS)
return () => {
cancelled = true
window.clearInterval(intervalId)
}
}, [jobId])
return (
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">
{runs === null ? (
<div className="flex items-center gap-1.5 py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon name="loading" size="0.75rem" spinning />
</div>
) : runs.length === 0 ? (
<div className="py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">{c.noRuns}</div>
) : (
<>
{runs.map(run => (
<button
className={cn(
'truncate rounded-md px-1.5 py-0.5 text-left text-[0.6875rem] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
run.id === selectedSessionId
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
key={run.id}
onClick={() => onOpenRun(run.id)}
type="button"
>
{formatRunTime(run.last_active || run.started_at)}
</button>
))}
</>
)}
</div>
)
}

View file

@ -40,10 +40,12 @@ import { useI18n } from '@/i18n'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { cn } from '@/lib/utils'
import { $cronJobs } from '@/store/cron'
import {
$panesFlipped,
$pinnedSessionIds,
$sidebarAgentsGrouped,
$sidebarCronOpen,
$sidebarOpen,
$sidebarPinsOpen,
$sidebarRecentsOpen,
@ -51,6 +53,7 @@ import {
reorderPinnedSession,
SESSION_SEARCH_FOCUS_EVENT,
setSidebarAgentsGrouped,
setSidebarCronOpen,
setSidebarPinsOpen,
setSidebarRecentsOpen,
SIDEBAR_SESSIONS_PAGE_SIZE,
@ -65,6 +68,7 @@ import {
normalizeProfileKey
} from '@/store/profile'
import {
$cronSessions,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
@ -78,6 +82,7 @@ import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '..
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
import { SidebarCronJobsSection } from './cron-jobs-section'
import { ProfileRail } from './profile-switcher'
import { SidebarSessionRow } from './session-row'
import { VirtualSessionList } from './virtual-session-list'
@ -223,6 +228,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
onNewSessionInWorkspace: (path: null | string) => void
onManageCronJob: (jobId: string) => void
onTriggerCronJob: (jobId: string) => void
}
export function ChatSidebar({
@ -233,7 +240,9 @@ export function ChatSidebar({
onResumeSession,
onDeleteSession,
onArchiveSession,
onNewSessionInWorkspace
onNewSessionInWorkspace,
onManageCronJob,
onTriggerCronJob
}: ChatSidebarProps) {
const { t } = useI18n()
const s = t.sidebar
@ -243,8 +252,11 @@ 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 cronJobs = useStore($cronJobs)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
@ -323,7 +335,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 +347,7 @@ export function ChatSidebar({
}
return map
}, [visibleSessions])
}, [visibleSessions, cronSessions])
const pinnedSessions = useMemo(() => {
const seen = new Set<string>()
@ -482,7 +497,9 @@ export function ChatSidebar({
])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.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 +776,18 @@ export function ChatSidebar({
/>
)}
{sidebarOpen && !trimmedQuery && cronJobs.length > 0 && (
<SidebarCronJobsSection
jobs={cronJobs}
label={s.cronJobs}
onManageJob={onManageCronJob}
onOpenRun={onResumeSession}
onToggle={() => setSidebarCronOpen(!cronOpen)}
onTriggerJob={onTriggerCronJob}
open={cronOpen}
/>
)}
{sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
{sidebarOpen && (

View file

@ -1,114 +0,0 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
interface CronJobActions {
busy?: boolean
isPaused: boolean
title: string
onDelete: () => void
onEdit: () => void
onPauseResume: () => void
onTrigger: () => void
}
interface CronJobActionsMenuProps
extends CronJobActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
children: React.ReactNode
}
export function CronJobActionsMenu({
align = 'end',
busy = false,
children,
isPaused,
onDelete,
onEdit,
onPauseResume,
onTrigger,
sideOffset = 6,
title
}: CronJobActionsMenuProps) {
const { t } = useI18n()
const c = t.cron
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={c.actionsFor(title)}
className="w-44"
sideOffset={sideOffset}
>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onPauseResume()
}}
>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
<span>{isPaused ? c.resumeTitle : c.pauseTitle}</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onTrigger()
}}
>
<Codicon name="zap" size="0.875rem" />
<span>{c.triggerNow}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onEdit()
}}
>
<Codicon name="edit" size="0.875rem" />
<span>{c.edit}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('warning')
onDelete()
}}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
title: string
}
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
const { t } = useI18n()
return (
<Button
aria-label={t.cron.actionsFor(title)}
className={className}
size="icon-sm"
title={t.cron.actionsTitle}
variant="ghost"
{...props}
>
<Codicon className="text-muted-foreground" name="ellipsis" size="0.875rem" />
</Button>
)
}

View file

@ -1,5 +1,6 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
@ -13,29 +14,33 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SearchField } from '@/components/ui/search-field'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
createCronJob,
type CronJob,
deleteCronJob,
getCronJobRuns,
getCronJobs,
pauseCronJob,
resumeCronJob,
type SessionInfo,
triggerCronJob,
updateCronJob
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, Clock } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
import { jobState, jobTitle, STATE_DOT } from './job-state'
const DEFAULT_DELIVER = 'local'
@ -80,28 +85,6 @@ function jobPrompt(job: CronJob): string {
return asText(job.prompt)
}
function jobTitle(job: CronJob): string {
const name = jobName(job)
if (name) {
return name
}
const prompt = jobPrompt(job)
if (prompt) {
return truncate(prompt, 60)
}
const script = asText(job.script)
if (script) {
return truncate(script, 60)
}
return job.id || 'Cron job'
}
function jobScheduleDisplay(job: CronJob): string {
return asText(job.schedule_display) || asText(job.schedule?.display) || asText(job.schedule?.expr) || '—'
}
@ -110,10 +93,6 @@ function jobScheduleExpr(job: CronJob): string {
return asText(job.schedule?.expr) || asText(job.schedule_display) || ''
}
function jobState(job: CronJob): string {
return asText(job.state) || (job.enabled === false ? 'disabled' : 'scheduled')
}
function jobDeliver(job: CronJob): string {
return asText(job.deliver) || DEFAULT_DELIVER
}
@ -261,31 +240,38 @@ function matchesQuery(job: CronJob, q: string): boolean {
interface CronViewProps extends React.ComponentProps<'section'> {
onClose: () => void
onOpenSession?: (sessionId: string) => void
setStatusbarItemGroup?: SetStatusbarItemGroup
}
export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setStatusbarItemGroup }: CronViewProps) {
const { t } = useI18n()
const c = t.cron
const [jobs, setJobs] = useState<CronJob[] | null>(null)
// Source of truth is the shared atom (also fed by the controller poll), so the
// sidebar and this overlay never drift — a delete here clears the sidebar row
// immediately. `loading` only gates the first paint before the atom is filled.
const jobs = useStore($cronJobs)
const [loading, setLoading] = useState(jobs.length === 0)
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [busyJobId, setBusyJobId] = useState<null | string>(null)
// Master/detail: the job whose schedule + run history fill the right pane.
const [selectedJobId, setSelectedJobId] = useState<null | string>(null)
// Set when a job is opened from the sidebar so we scroll it into view once the
// row exists. Cleared after the scroll fires.
const pendingScrollRef = useRef<null | string>(null)
const focusJobId = useStore($cronFocusJobId)
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const result = await getCronJobs()
setJobs(result)
setCronJobs(await getCronJobs())
} catch (err) {
notifyError(err, c.failedLoad)
} finally {
setRefreshing(false)
setLoading(false)
}
}, [c])
@ -295,16 +281,47 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
void refresh()
}, [refresh])
const visibleJobs = useMemo(() => {
if (!jobs) {
return []
// Sidebar → "open this job": resolve the focus id (or name) to a job, select
// it, queue a scroll, then clear the one-shot focus so re-opening cron
// normally doesn't re-trigger it.
useEffect(() => {
if (!focusJobId) {return}
const match = jobs.find(job => job.id === focusJobId || jobName(job) === focusJobId)
if (match) {
setSelectedJobId(match.id)
pendingScrollRef.current = match.id
}
return jobs.filter(job => matchesQuery(job, query.trim())).sort((a, b) => jobTitle(a).localeCompare(jobTitle(b)))
}, [jobs, query])
setCronFocusJobId(null)
}, [focusJobId, jobs])
const enabledCount = jobs?.filter(job => job.enabled).length ?? 0
const totalCount = jobs?.length ?? 0
const visibleJobs = useMemo(
() => jobs.filter(job => matchesQuery(job, query.trim())).sort((a, b) => jobTitle(a).localeCompare(jobTitle(b))),
[jobs, query]
)
// Detail always reflects a concrete job: the explicitly selected one, else the
// first visible row, so the right pane is never empty while jobs exist.
const selectedJob = useMemo(
() => visibleJobs.find(job => job.id === selectedJobId) ?? visibleJobs[0] ?? null,
[visibleJobs, selectedJobId]
)
// Scroll a sidebar-opened job into view once its list row is mounted.
useEffect(() => {
const target = pendingScrollRef.current
if (!target || selectedJob?.id !== target) {return}
pendingScrollRef.current = null
requestAnimationFrame(() => {
document.querySelector(`[data-cron-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' })
})
}, [selectedJob])
const totalCount = jobs.length
async function handlePauseResume(job: CronJob) {
setBusyJobId(job.id)
@ -312,7 +329,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
try {
const isPaused = jobState(job) === 'paused'
const updated = isPaused ? await resumeCronJob(job.id) : await pauseCronJob(job.id)
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
updateCronJobs(rows => rows.map(row => (row.id === job.id ? updated : row)))
notify({
kind: 'success',
title: isPaused ? c.resumed : c.paused,
@ -330,7 +347,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
try {
const updated = await triggerCronJob(job.id)
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
updateCronJobs(rows => rows.map(row => (row.id === job.id ? updated : row)))
notify({ kind: 'success', title: c.triggered, message: truncate(jobTitle(job), 60) })
} catch (err) {
notifyError(err, c.failedTrigger)
@ -348,7 +365,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
try {
await deleteCronJob(pendingDelete.id)
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
updateCronJobs(rows => rows.filter(row => row.id !== pendingDelete.id))
notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) })
setPendingDelete(null)
} catch (err) {
@ -367,7 +384,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
deliver: values.deliver || DEFAULT_DELIVER
})
setJobs(current => (current ? [...current, created] : [created]))
updateCronJobs(rows => [...rows, created])
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
} else if (editor.mode === 'edit') {
const updated = await updateCronJob(editor.job.id, {
@ -377,7 +394,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
deliver: values.deliver
})
setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current))
updateCronJobs(rows => rows.map(row => (row.id === updated.id ? updated : row)))
notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) })
}
@ -386,71 +403,62 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
return (
<OverlayView closeLabel={c.close} onClose={onClose}>
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchPlaceholder={c.search}
searchTrailingAction={
<Button
aria-label={refreshing ? c.refreshing : c.refresh}
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
disabled={refreshing}
onClick={() => void refresh()}
size="icon-xs"
title={refreshing ? c.refreshing : c.refresh}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!jobs ? (
<PageLoader label={c.loading} />
) : visibleJobs.length === 0 ? (
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
// matches") just asks the user to broaden their query.
<EmptyState
actionLabel={totalCount === 0 ? c.createFirst : undefined}
description={totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
/>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We
still need a single, always-visible affordance to add a job
when the list is non-empty (rows themselves only expose
edit/pause/trigger/delete). */}
<div className="mb-2 flex items-center justify-between">
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
{c.active(enabledCount, totalCount)}
</span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
{c.newCron}
</Button>
</div>
<div className="divide-y divide-(--ui-stroke-tertiary)">
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
c={c}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
onEdit={() => setEditor({ mode: 'edit', job })}
onPauseResume={() => void handlePauseResume(job)}
onTrigger={() => void handleTrigger(job)}
/>
))}
</div>
</div>
)}
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
{loading && jobs.length === 0 ? (
<PageLoader label={c.loading} />
) : (
<OverlaySplitLayout>
<OverlaySidebar>
<OverlayNewButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
{totalCount > 0 && (
<SearchField
aria-label={c.search}
containerClassName="mb-1 w-full px-2"
onChange={setQuery}
placeholder={c.search}
value={query}
/>
)}
{visibleJobs.map(job => (
<CronJobListRow
active={selectedJob?.id === job.id}
c={c}
job={job}
key={job.id}
onSelect={() => setSelectedJobId(job.id)}
/>
))}
{visibleJobs.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
{totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
</p>
)}
</OverlaySidebar>
<OverlayMain className="px-0">
{selectedJob ? (
<CronJobDetail
busy={busyJobId === selectedJob.id}
c={c}
job={selectedJob}
onDelete={() => setPendingDelete(selectedJob)}
onEdit={() => setEditor({ mode: 'edit', job: selectedJob })}
onOpenSession={onOpenSession}
onPauseResume={() => void handlePauseResume(selectedJob)}
onTrigger={() => void handleTrigger(selectedJob)}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Clock className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">{totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}</p>
</div>
</div>
)}
</OverlayMain>
</OverlaySplitLayout>
)}
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
@ -476,17 +484,52 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
</DialogFooter>
</DialogContent>
</Dialog>
</PageSearchShell>
</OverlayView>
)
}
function CronJobRow({
function CronJobListRow({
active,
c,
job,
onSelect
}: {
active: boolean
c: Translations['cron']
job: CronJob
onSelect: () => void
}) {
const state = jobState(job)
return (
<button
className={cn(
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
)}
data-cron-row={job.id}
onClick={onSelect}
type="button"
>
<span className="flex w-full items-center gap-2">
<span
aria-hidden="true"
className={cn('size-1.5 shrink-0 rounded-full', STATE_DOT[state] ?? 'bg-muted-foreground')}
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{jobTitle(job)}</span>
</span>
<span className="truncate pl-3.5 text-[0.66rem] text-muted-foreground">{jobScheduleDisplay(job)}</span>
</button>
)
}
function CronJobDetail({
busy,
c,
job,
onDelete,
onEdit,
onOpenSession,
onPauseResume,
onTrigger
}: {
@ -495,71 +538,172 @@ function CronJobRow({
job: CronJob
onDelete: () => void
onEdit: () => void
onOpenSession?: (sessionId: string) => void
onPauseResume: () => void
onTrigger: () => void
}) {
const state = jobState(job)
const isPaused = state === 'paused'
const hasName = Boolean(jobName(job))
const prompt = jobPrompt(job)
const deliver = jobDeliver(job)
const prompt = jobPrompt(job)
return (
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
<button
className="min-w-0 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={onEdit}
type="button"
>
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{jobTitle(job)}</span>
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
{deliver && deliver !== DEFAULT_DELIVER && (
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
)}
</div>
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.68rem] text-muted-foreground">
<span className="inline-flex items-center gap-1 font-mono">
<Clock className="size-3" />
{jobScheduleDisplay(job)}
</span>
<span>
{c.last} {formatTime(job.last_run_at)}
</span>
<span>
{c.next} {formatTime(job.next_run_at)}
</span>
</div>
{job.last_error && (
<p className="mt-1 inline-flex items-start gap-1 text-[0.68rem] text-destructive">
<AlertTriangle className="mt-px size-3 shrink-0" />
<span className="line-clamp-2">{job.last_error}</span>
</p>
)}
</button>
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<header className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-xl font-semibold tracking-tight">{jobTitle(job)}</h3>
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
{deliver && deliver !== DEFAULT_DELIVER && (
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
)}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.7rem] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock className="size-3" />
{jobScheduleDisplay(job)}
</span>
<span>
{c.last} {formatTime(job.last_run_at)}
</span>
<span>
{c.next} {formatTime(job.next_run_at)}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button disabled={busy} onClick={onPauseResume} size="sm" variant="outline">
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
{isPaused ? c.resumeTitle : c.pauseTitle}
</Button>
<Button disabled={busy} onClick={onTrigger} size="sm" variant="outline">
<Codicon name="zap" size="0.875rem" />
{c.triggerNow}
</Button>
<Button onClick={onEdit} size="sm" variant="outline">
<Codicon name="edit" size="0.875rem" />
{c.edit}
</Button>
<Button
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
size="sm"
variant="ghost"
>
<Codicon name="trash" size="0.875rem" />
</Button>
</div>
</div>
<div className="flex shrink-0 items-center">
<CronJobActionsMenu
busy={busy}
isPaused={isPaused}
onDelete={onDelete}
onEdit={onEdit}
onPauseResume={onPauseResume}
onTrigger={onTrigger}
title={jobTitle(job)}
>
<CronJobActionsTrigger
className="text-muted-foreground hover:text-foreground"
onClick={event => event.stopPropagation()}
title={jobTitle(job)}
/>
</CronJobActionsMenu>
{prompt && <p className="line-clamp-3 text-xs text-muted-foreground">{prompt}</p>}
{job.last_error && (
<p className="inline-flex items-start gap-1 text-[0.7rem] text-destructive">
<AlertTriangle className="mt-px size-3 shrink-0" />
<span className="line-clamp-2">{job.last_error}</span>
</p>
)}
</header>
<CronJobRuns c={c} jobId={job.id} onOpenSession={onOpenSession} />
</div>
</div>
</div>
)
}
function formatRunTime(seconds?: null | number): string {
if (!seconds) {
return '—'
}
const date = new Date(seconds * 1000)
return Number.isNaN(date.valueOf()) ? '—' : date.toLocaleString()
}
// Runs are produced by the background scheduler tick (no UI signal), so poll
// while the panel is open + on tab re-focus so a fired run shows up within a few
// seconds instead of waiting for a reload.
const RUNS_POLL_INTERVAL_MS = 8000
function CronJobRuns({
c,
jobId,
onOpenSession
}: {
c: Translations['cron']
jobId: string
onOpenSession?: (sessionId: string) => void
}) {
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
useEffect(() => {
let cancelled = false
const load = () =>
getCronJobRuns(jobId)
.then(result => {
if (!cancelled) {setRuns(result)}
})
.catch(() => {
if (!cancelled) {setRuns(prev => prev ?? [])}
})
void load()
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') {void load()}
}, RUNS_POLL_INTERVAL_MS)
const onVisible = () => {
if (document.visibilityState === 'visible') {void load()}
}
document.addEventListener('visibilitychange', onVisible)
return () => {
cancelled = true
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', onVisible)
}
}, [jobId])
return (
<div>
<div className="mb-1.5 text-[0.62rem] font-medium uppercase tracking-wide text-muted-foreground">
{c.runHistory}
{runs && runs.length > 0 ? ` · ${runs.length}` : ''}
</div>
{runs === null ? (
<div className="flex items-center gap-1.5 py-1 text-xs text-muted-foreground">
<Codicon name="loading" size="0.75rem" spinning />
</div>
) : runs.length === 0 ? (
<div className="py-1 text-xs text-muted-foreground">{c.noRuns}</div>
) : (
<div className="flex flex-col gap-px">
{runs.map(run => (
<button
className="flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs hover:bg-(--chrome-action-hover) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
key={run.id}
onClick={() => onOpenSession?.(run.id)}
type="button"
>
<span className="truncate text-foreground">{run.title?.trim() || run.preview?.trim() || run.id}</span>
<span className="shrink-0 text-[0.62rem] text-muted-foreground tabular-nums">
{formatRunTime(run.last_active || run.started_at)}
</span>
</button>
))}
</div>
)}
</div>
)
}
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
return (
<span
@ -570,33 +714,6 @@ function StatePill({ children, tone }: { children: string; tone: keyof typeof PI
)
}
function EmptyState({
actionLabel,
description,
onAction,
title
}: {
actionLabel?: string
description: string
onAction?: () => void
title: string
}) {
return (
<div className="grid h-full place-items-center px-6 py-12 text-center">
<div className="max-w-sm space-y-2">
<div className="text-sm font-medium">{title}</div>
<p className="text-xs text-muted-foreground">{description}</p>
{actionLabel && onAction && (
<Button className="mt-2" onClick={onAction} size="sm">
<Codicon name="add" />
{actionLabel}
</Button>
)}
</div>
</div>
)
}
function CronEditorDialog({
editor,
onClose,
@ -753,7 +870,7 @@ function CronEditorDialog({
<FieldHint>{c.customHint}</FieldHint>
</Field>
) : (
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2">
<div className="rounded-md bg-(--ui-bg-quinary) px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<span className="font-medium text-foreground">{scheduleHint}</span>
<span className="font-mono text-muted-foreground">{schedule}</span>
@ -762,7 +879,7 @@ function CronEditorDialog({
)}
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>

View file

@ -0,0 +1,29 @@
import type { CronJob } from '@/types/hermes'
// Status-pip color per cron job state. Single source for the sidebar section and
// the Cron page so the two never drift. (Animation/size live at the call site.)
export const STATE_DOT: Record<string, string> = {
completed: 'bg-(--ui-text-quaternary)',
disabled: 'bg-(--ui-text-quaternary)',
enabled: 'bg-primary',
error: 'bg-destructive',
paused: 'bg-amber-500',
running: 'bg-primary',
scheduled: 'bg-primary'
}
// Effective state: explicit state wins; otherwise infer from the enabled flag.
export function jobState(job: CronJob): string {
const state = typeof job.state === 'string' ? job.state.trim() : ''
return state || (job.enabled === false ? 'disabled' : 'scheduled')
}
// Human label for a job: name → first 60 of prompt → first 60 of script → id.
// One source for the sidebar row and the Cron page so the two never drift.
export function jobTitle(job: CronJob): string {
const pick = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
const clip = (v: string) => (v.length > 60 ? `${v.slice(0, 60)}` : v)
return pick(job.name) || clip(pick(job.prompt)) || clip(pick(job.script)) || job.id || 'Cron job'
}

View file

@ -11,8 +11,9 @@ import { Pane, PaneMain } from '@/components/pane-shell'
import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getSessionMessages, listAllProfileSessions, type SessionInfo } from '../hermes'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { setCronFocusJobId, setCronJobs } from '../store/cron'
import {
$panesFlipped,
$pinnedSessionIds,
@ -37,10 +38,12 @@ import {
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
mergeSessionPage,
sessionPinId,
setAwaitingResponse,
setBusy,
setCronSessions,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
@ -71,7 +74,7 @@ import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
import { useCwdActions } from './session/hooks/use-cwd-actions'
import { useHermesConfig } from './session/hooks/use-hermes-config'
@ -101,6 +104,21 @@ 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
// Cron sessions are written by a background scheduler tick (the desktop
// backend), so no user action signals the UI. Poll the bounded cron list on
// this cadence while the app is open + visible so new runs surface promptly
// instead of waiting for the next user-triggered refreshSessions().
const CRON_POLL_INTERVAL_MS = 30_000
// Cheap signature compare so the poll only swaps the atom (and re-renders the
// sidebar) when the visible cron rows actually changed.
function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
if (a.length !== b.length) {return false}
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
}
// 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 +242,36 @@ 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. 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.
}
}, [])
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
// synchronously (agent tool call or the cron UI), so refreshing here right
// after an agent turn surfaces a new job immediately; the interval poll keeps
// next-run/state fresh as the scheduler advances them.
const refreshCronJobs = useCallback(async () => {
try {
const jobs = await getCronJobs()
setCronJobs(jobs)
} catch {
// Non-fatal: the cron section just keeps its last-known jobs.
}
}, [])
const refreshSessions = useCallback(async () => {
const requestId = refreshSessionsRequestRef.current + 1
refreshSessionsRequestRef.current = requestId
@ -231,13 +279,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 +302,10 @@ export function DesktopController() {
setSessionsLoading(false)
}
}
}, [])
void refreshCronSessions()
void refreshCronJobs()
}, [refreshCronSessions, refreshCronJobs])
const loadMoreSessions = useCallback(() => {
bumpSessionsLimit()
@ -262,7 +318,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)])
@ -560,6 +620,25 @@ export function DesktopController() {
}
}, [gatewayState, refreshCurrentModel, refreshSessions])
// Keep the cron jobs section live without a user action: the scheduler ticks
// in the background (advancing next-run/state and creating runs), so poll the
// job list on an interval (and on tab re-focus) while connected.
useEffect(() => {
if (gatewayState !== 'open') {return}
const tick = () => {
if (document.visibilityState === 'visible') {void refreshCronJobs()}
}
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
document.addEventListener('visibilitychange', tick)
return () => {
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', tick)
}
}, [gatewayState, refreshCronJobs])
useRouteResume({
activeSessionId,
activeSessionIdRef,
@ -600,9 +679,18 @@ export function DesktopController() {
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
onLoadMoreSessions={loadMoreSessions}
onManageCronJob={jobId => {
setCronFocusJobId(jobId)
navigate(CRON_ROUTE)
}}
onNavigate={selectSidebarItem}
onNewSessionInWorkspace={startSessionInWorkspace}
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
onTriggerCronJob={jobId => {
void triggerCronJob(jobId)
.then(() => refreshCronJobs())
.catch(() => undefined)
}}
/>
)
@ -669,7 +757,10 @@ export function DesktopController() {
{cronOpen && (
<Suspense fallback={null}>
<CronView onClose={closeOverlayToPreviousRoute} />
<CronView
onClose={closeOverlayToPreviousRoute}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
)}

View file

@ -1,5 +1,7 @@
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
@ -73,6 +75,31 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
)
}
// Boxless "+ New …" action that tops an OverlaySidebar list (profiles, cron, …).
// The text variant underlines on hover, which also strokes the icon glyph — so
// we keep the button itself underline-free and underline only the label span.
export function OverlayNewButton({
icon = 'add',
label,
onClick
}: {
icon?: string
label: string
onClick: () => void
}) {
return (
<Button
className="group mb-1 w-full justify-start gap-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={onClick}
size="sm"
variant="ghost"
>
<Codicon name={icon} />
<span className="underline-offset-4 group-hover:underline">{label}</span>
</Button>
)
}
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
return (
<button

View file

@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
@ -30,7 +29,7 @@ import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@ -145,15 +144,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
) : (
<OverlaySplitLayout>
<OverlaySidebar>
<Button
className="mb-1 w-full justify-start gap-2"
onClick={() => setCreateOpen(true)}
size="sm"
variant="text"
>
<Codicon name="add" />
{p.newProfile}
</Button>
<OverlayNewButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
{profiles.map(profile => (
<ProfileRow
active={selected?.name === profile.name}

View file

@ -90,6 +90,7 @@ const TOOL_META: Record<string, ToolMeta> = {
},
browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' },
clarify: { done: 'Asked a question', pending: 'Asking a question', icon: 'question', tone: 'agent' },
cronjob: { done: 'Cron job', pending: 'Scheduling cron job', icon: 'watch', tone: 'agent' },
edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' },
execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' },
image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' },
@ -899,6 +900,80 @@ function fallbackDetailText(args: unknown, result: unknown): string {
return formatToolResultSummary(args) || minimalValueSummary(args)
}
function cronScalar(value: unknown): string {
if (typeof value === 'string') return value.trim()
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
return ''
}
function formatCronTime(iso: string): string {
const ts = Date.parse(iso)
if (Number.isNaN(ts)) return iso
return new Date(ts).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function cronjobSubtitle(
argsRecord: Record<string, unknown>,
resultRecord: Record<string, unknown>
): string {
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
if (jobs) {
return jobs.length ? `${jobs.length} cron job${jobs.length === 1 ? '' : 's'}` : 'No cron jobs'
}
const message = firstStringField(resultRecord, ['message'])
if (message) return message
const action = firstStringField(argsRecord, ['action']) || 'manage'
const name = firstStringField(resultRecord, ['name']) || firstStringField(argsRecord, ['name', 'job_id'])
const label = `${action[0]?.toUpperCase() ?? ''}${action.slice(1)}`
return name ? `${label} ${name}` : `Cron ${action}`
}
function cronjobDetail(
argsRecord: Record<string, unknown>,
resultRecord: Record<string, unknown>
): string {
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
if (jobs) {
if (!jobs.length) return 'No cron jobs scheduled'
return jobs
.slice(0, 20)
.map(job => {
const row = isRecord(job) ? job : {}
const name = firstStringField(row, ['name', 'id']) || 'job'
const sched = firstStringField(row, ['schedule_display', 'schedule'])
return sched ? `- ${name} · ${sched}` : `- ${name}`
})
.join('\n')
}
const nextRun = cronScalar(resultRecord.next_run_at)
const rows: [string, string][] = [
['Schedule', cronScalar(resultRecord.schedule)],
['Repeat', cronScalar(resultRecord.repeat)],
['Delivery', cronScalar(resultRecord.deliver)],
['Next run', nextRun ? formatCronTime(nextRun) : '']
]
const lines = rows.filter(([, value]) => value).map(([key, value]) => `${key}: ${value}`)
return lines.length ? lines.join('\n') : fallbackDetailText(argsRecord, resultRecord)
}
function toolSubtitle(
part: ToolPart,
argsRecord: Record<string, unknown>,
@ -992,6 +1067,10 @@ function toolSubtitle(
return url ? hostnameOf(url) : 'Fetched webpage'
}
if (toolName === 'cronjob') {
return cronjobSubtitle(argsRecord, resultRecord)
}
return (
compactPreview(formatToolResultSummary(part.result), 120) ||
compactPreview(resultRecord, 120) ||
@ -1092,6 +1171,10 @@ function toolDetailText(
.replace(/\bDuration\s+S\s*:/gi, 'Duration:')
}
if (part.toolName === 'cronjob') {
return cronjobDetail(argsRecord, resultRecord)
}
return fallbackDetailText(argsRecord, resultRecord)
}

View file

@ -32,6 +32,7 @@ import type {
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
SessionInfo,
SessionMessagesResponse,
SessionSearchResponse,
SkillInfo,
@ -149,17 +150,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 {
@ -488,6 +505,14 @@ export function getCronJob(jobId: string): Promise<CronJob> {
})
}
export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`
})
return runs ?? []
}
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
path: '/api/cron/jobs',

View file

@ -897,8 +897,6 @@ export const en: Translations = {
cron: {
close: 'Close cron',
search: 'Search cron jobs...',
refresh: 'Refresh cron jobs',
refreshing: 'Refreshing cron jobs',
loading: 'Loading cron jobs...',
states: {
enabled: 'enabled',
@ -951,9 +949,7 @@ export const en: Translations = {
monthlyOnDayAt: (dayOfMonth, time) => `Monthly on day ${dayOfMonth} at ${time}`,
topOfHour: 'At the top of every hour',
everyHourAt: minute => `Every hour at :${minute}`,
active: (enabled, total) => `${enabled}/${total} active`,
newCron: 'New cron',
createFirst: 'Create first cron',
emptyDescNew:
'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.',
emptyDescSearch: 'Try a broader search query.',
@ -961,6 +957,11 @@ export const en: Translations = {
emptyTitleSearch: 'No matches',
last: 'Last:',
next: 'Next:',
noRuns: 'No runs yet',
manage: 'Manage',
showRuns: 'Show runs',
hideRuns: 'Hide runs',
runHistory: 'Run history',
actionsFor: title => `Actions for ${title}`,
actionsTitle: 'Cron job actions',
resume: 'Resume cron',
@ -1052,6 +1053,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

@ -999,8 +999,6 @@ export const ja = defineLocale({
cron: {
close: 'Cron を閉じる',
search: 'Cron ジョブを検索...',
refresh: 'Cron ジョブを更新',
refreshing: 'Cron ジョブを更新中',
loading: 'Cron ジョブを読み込み中...',
states: {
enabled: '有効',
@ -1053,9 +1051,7 @@ export const ja = defineLocale({
monthlyOnDayAt: (dayOfMonth, time) => `毎月 ${dayOfMonth}${time}`,
topOfHour: '毎時 0 分',
everyHourAt: minute => `毎時 :${minute}`,
active: (enabled, total) => `${enabled}/${total} 有効`,
newCron: '新しい Cron',
createFirst: '最初の Cron を作成',
emptyDescNew:
'Cron 式でプロンプトを実行するスケジュールを設定します。Hermes が実行して、選択した宛先に結果を送信します。',
emptyDescSearch: '検索キーワードを広げてください。',
@ -1063,6 +1059,11 @@ export const ja = defineLocale({
emptyTitleSearch: '一致なし',
last: '前回',
next: '次回',
noRuns: 'まだ実行されていません',
manage: '管理',
showRuns: '実行履歴を表示',
hideRuns: '実行履歴を隠す',
runHistory: '実行履歴',
actionsFor: title => `${title} のアクション`,
actionsTitle: 'Cron ジョブのアクション',
resume: '再開',
@ -1155,6 +1156,7 @@ export const ja = defineLocale({
results: '結果',
pinned: 'ピン留め',
sessions: 'セッション',
cronJobs: 'Cronジョブ',
groupAriaGrouped: 'セッションを単一リストとして表示',
groupAriaUngrouped: 'ワークスペースごとにセッションをグループ化',
groupTitleGrouped: 'セッションのグループ化を解除',

View file

@ -699,8 +699,6 @@ export interface Translations {
cron: {
close: string
search: string
refresh: string
refreshing: string
loading: string
states: Record<string, string>
deliveryLabels: Record<string, string>
@ -714,15 +712,18 @@ export interface Translations {
monthlyOnDayAt: (dayOfMonth: string, time: string) => string
topOfHour: string
everyHourAt: (minute: string) => string
active: (enabled: number, total: number) => string
newCron: string
createFirst: string
emptyDescNew: string
emptyDescSearch: string
emptyTitleNew: string
emptyTitleSearch: string
last: string
next: string
noRuns: string
manage: string
showRuns: string
hideRuns: string
runHistory: string
actionsFor: (title: string) => string
actionsTitle: string
resume: string
@ -809,6 +810,7 @@ export interface Translations {
results: string
pinned: string
sessions: string
cronJobs: string
groupAriaGrouped: string
groupAriaUngrouped: string
groupTitleGrouped: string

View file

@ -966,8 +966,6 @@ export const zhHant = defineLocale({
cron: {
close: '關閉排程',
search: '搜尋排程工作…',
refresh: '重新整理排程工作',
refreshing: '正在重新整理排程工作',
loading: '正在載入排程工作…',
states: {
enabled: '已啟用',
@ -1020,9 +1018,7 @@ export const zhHant = defineLocale({
monthlyOnDayAt: (dayOfMonth, time) => `每月 ${dayOfMonth}${time}`,
topOfHour: '每個整點',
everyHourAt: minute => `每小時的 :${minute}`,
active: (enabled, total) => `${enabled}/${total} 個啟用`,
newCron: '新排程工作',
createFirst: '建立第一個排程工作',
emptyDescNew:
'按 cron 表達式排程一個提示詞。Hermes 會執行它,並將結果傳送至您選擇的目的地。',
emptyDescSearch: '請嘗試更廣泛的搜尋詞。',
@ -1030,6 +1026,11 @@ export const zhHant = defineLocale({
emptyTitleSearch: '無相符項目',
last: '上次:',
next: '下次:',
noRuns: '尚無執行',
manage: '管理',
showRuns: '顯示執行記錄',
hideRuns: '隱藏執行記錄',
runHistory: '執行記錄',
actionsFor: title => `${title} 的動作`,
actionsTitle: '排程工作動作',
resume: '繼續',
@ -1121,6 +1122,7 @@ export const zhHant = defineLocale({
results: '結果',
pinned: '已釘選',
sessions: '工作階段',
cronJobs: '排程任務',
groupAriaGrouped: '以單一清單顯示工作階段',
groupAriaUngrouped: '依工作區分組工作階段',
groupTitleGrouped: '取消分組',

View file

@ -1045,8 +1045,6 @@ export const zh: Translations = {
cron: {
close: '关闭定时任务',
search: '搜索定时任务…',
refresh: '刷新定时任务',
refreshing: '正在刷新定时任务',
loading: '正在加载定时任务…',
states: {
enabled: '已启用',
@ -1099,15 +1097,18 @@ export const zh: Translations = {
monthlyOnDayAt: (dayOfMonth, time) => `每月 ${dayOfMonth}${time}`,
topOfHour: '每个整点',
everyHourAt: minute => `每小时的 :${minute}`,
active: (enabled, total) => `${enabled}/${total} 个启用`,
newCron: '新建定时任务',
createFirst: '创建第一个定时任务',
emptyDescNew: '按 cron 表达式排程一个提示词。Hermes 会运行它,并把结果发送到你选择的目的地。',
emptyDescSearch: '尝试更宽泛的搜索词。',
emptyTitleNew: '暂无排程任务',
emptyTitleSearch: '无匹配项',
last: '上次:',
next: '下次:',
noRuns: '尚无运行',
manage: '管理',
showRuns: '显示运行记录',
hideRuns: '隐藏运行记录',
runHistory: '运行记录',
actionsFor: title => `${title} 的操作`,
actionsTitle: '定时任务操作',
resume: '恢复定时任务',
@ -1199,6 +1200,7 @@ export const zh: Translations = {
results: '结果',
pinned: '已置顶',
sessions: '会话',
cronJobs: '定时任务',
groupAriaGrouped: '以单一列表显示会话',
groupAriaUngrouped: '按工作区分组会话',
groupTitleGrouped: '取消分组',

View file

@ -0,0 +1,19 @@
import { atom } from 'nanostores'
import type { CronJob } from '@/types/hermes'
// Cron *jobs* (not run sessions) power the sidebar "Cron jobs" section. Listing
// the job — schedule, state, live next-run countdown — makes the job the
// first-class entity; its runs (sessions) resolve under it in the cron detail.
export const $cronJobs = atom<CronJob[]>([])
export const setCronJobs = (jobs: CronJob[]) => $cronJobs.set(jobs)
// In-place edit so the cron overlay's mutations (create/edit/delete/pause/…)
// land in the same atom the sidebar renders — no stale list until the next poll.
export const updateCronJobs = (fn: (jobs: CronJob[]) => CronJob[]) => $cronJobs.set(fn($cronJobs.get()))
// One-shot focus target: clicking "Manage" on a job sets this, then opens the
// cron overlay, which reads it once to select + scroll to that job. Cleared
// after consumption so re-opening cron normally doesn't re-focus a stale job.
export const $cronFocusJobId = atom<null | string>(null)
export const setCronFocusJobId = (id: null | string) => $cronFocusJobId.set(id)

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,15 @@ 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[]>([])
// Max cron sessions fetched for the sidebar section (single bounded page). When
// the fetch returns exactly this many rows we know more exist, so the section
// badge renders "N+". Lives here so the controller (fetch) and sidebar (badge)
// share one source of truth without a circular import.
export const CRON_SECTION_LIMIT = 50
// 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 +128,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

@ -1973,6 +1973,18 @@ def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
for _var_name in _cron_delivery_vars:
_VAR_MAP[_var_name].set("")
if _session_db:
# Title the cron session from the job (name → short prompt → id) so
# sidebars/history show a meaningful label instead of the injected
# "[IMPORTANT: …]" hint that is the session's first message. Set here
# (not at create time) so the agent's own INSERT keeps model /
# system_prompt; this only UPDATEs the title column. The run-time
# suffix keeps it unique against the sessions.title index across runs.
try:
_title_base = " ".join(job_name.split())[:60].strip() or f"cron {job_id}"
_cron_title = f"{_title_base} · {_hermes_now().strftime('%b %d %H:%M')}"
_session_db.set_session_title(_cron_session_id, _cron_title)
except (Exception, KeyboardInterrupt) as e:
logger.debug("Job '%s': failed to set cron session title: %s", job_id, e)
try:
_session_db.end_session(_cron_session_id, "cron_complete")
except (Exception, KeyboardInterrupt) as e:

View file

@ -102,11 +102,55 @@ _log = logging.getLogger(__name__)
# when the same module is used across TestClient instances or uvicorn reloads.
# ---------------------------------------------------------------------------
def _start_desktop_cron_ticker(stop_event: "threading.Event", interval: int = 60) -> None:
"""Tick the cron scheduler from inside the desktop dashboard backend.
The scheduler tick loop normally lives in ``hermes gateway run`` but the
desktop app spawns a ``hermes dashboard`` backend, not a gateway, so a cron
a user creates in the app would never fire. We run a minimal ticker here
(no live adapters; delivery falls back to the per-platform send path).
Cross-process safe: ``cron.scheduler.tick`` takes the ``cron/.tick.lock``
file lock, so this never double-fires alongside a real gateway on the same
HERMES_HOME whichever process grabs the lock first wins the tick.
"""
from cron.scheduler import tick as cron_tick
_log.info("Desktop cron ticker started (interval=%ds)", interval)
# Tick once up front (catches jobs due at launch), then on the interval.
while not stop_event.is_set():
try:
cron_tick(verbose=False, sync=False)
except Exception as e:
_log.debug("Desktop cron tick error: %s", e)
stop_event.wait(interval)
@asynccontextmanager
async def _lifespan(app: "FastAPI"):
app.state.event_channels = {} # dict[str, set]
app.state.event_lock = asyncio.Lock()
yield
# Desktop-spawned backends (HERMES_DESKTOP=1) fire cron jobs themselves,
# since the app has no gateway running the scheduler. Server `hermes
# dashboard` is unaffected — it relies on its own gateway.
cron_stop: "threading.Event | None" = None
cron_thread: "threading.Thread | None" = None
if os.getenv("HERMES_DESKTOP") == "1":
cron_stop = threading.Event()
cron_thread = threading.Thread(
target=_start_desktop_cron_ticker,
args=(cron_stop,),
daemon=True,
name="desktop-cron-ticker",
)
cron_thread.start()
try:
yield
finally:
if cron_stop is not None:
cron_stop.set()
def _get_event_state(app: "FastAPI"):
@ -1574,6 +1618,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 +1650,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 +1666,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 +1697,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 +1734,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 +1762,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 +1772,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,
@ -5584,6 +5650,53 @@ async def get_cron_job(job_id: str, profile: Optional[str] = None):
return job
@app.get("/api/cron/jobs/{job_id}/runs")
async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit: int = 20):
"""Run sessions produced by a cron job, newest first.
Cron runs are stored as ordinary sessions whose id is
``cron_{job_id}_{timestamp}`` (see cron/scheduler.run_job). A job's history
is therefore every session whose id carries that prefix; ``source='cron'``
narrows it and the id substring binds it to this job. Powers the run-history
list under each job in the desktop cron detail. Same row shape as
``/api/sessions`` so the frontend can reuse SessionInfo.
"""
selected = profile or _find_cron_job_profile(job_id)
# job_id may be a human name; resolve to the canonical id used in run-session ids.
canonical = job_id
if selected:
job = _call_cron_for_profile(selected, "get_job", job_id)
if job and job.get("id"):
canonical = str(job["id"])
try:
limit_n = max(1, min(int(limit), 100))
except (TypeError, ValueError):
limit_n = 20
db = _open_session_db_for_profile(selected)
try:
runs = db.list_sessions_rich(
source="cron",
id_query=f"cron_{canonical}_",
limit=limit_n,
offset=0,
order_by_last_active=True,
)
now = time.time()
for s in runs:
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"))
if selected:
s["profile"] = selected
return {"runs": runs, "limit": limit_n}
finally:
db.close()
@app.post("/api/cron/jobs")
async def create_cron_job(body: CronJobCreate, profile: str = "default"):
try:

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)

View file

@ -912,6 +912,43 @@ class TestRunJobSessionPersistence:
fake_db.close.assert_called_once()
mock_agent.close.assert_called_once()
def test_run_job_titles_cron_session_from_job_not_important_hint(self, tmp_path):
# The cron session's first message is the injected "[IMPORTANT: …]"
# hint, which used to surface as the sidebar/history row label. run_job
# must title the session from the job (name → short prompt → id).
job = {
"id": "test-job",
"name": "Morning digest",
"prompt": "summarize my inbox",
}
fake_db = MagicMock()
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("dotenv.load_dotenv"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
"api_key": "test-key",
"base_url": "https://example.invalid/v1",
"provider": "openrouter",
"api_mode": "chat_completions",
},
), \
patch("run_agent.AIAgent") as mock_agent_cls:
mock_agent = MagicMock()
mock_agent.run_conversation.return_value = {"final_response": "ok"}
mock_agent_cls.return_value = mock_agent
run_job(job)
fake_db.set_session_title.assert_called_once()
sid, title = fake_db.set_session_title.call_args[0]
assert sid.startswith("cron_test-job_")
assert "IMPORTANT" not in title
assert title.startswith("Morning digest")
def test_run_job_closes_agent_on_failure_to_prevent_fd_leak(self, tmp_path):
# Regression: if ``run_conversation`` raises, the ephemeral cron
# agent was previously leaked — over days of ticks this accumulated

View file

@ -4264,3 +4264,38 @@ class TestValidateProviderCredential:
def test_empty_value_rejected(self):
data = self._post("OPENAI_API_KEY", " ").json()
assert data["ok"] is False
class TestDesktopCronTicker:
"""The dashboard backend fires cron jobs itself only when desktop-spawned."""
def _client(self):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app
return TestClient(app)
def test_ticker_runs_when_desktop(self, monkeypatch, _isolate_hermes_home):
import threading
import cron.scheduler as sched
called = threading.Event()
monkeypatch.setattr(sched, "tick", lambda *a, **k: called.set())
monkeypatch.setenv("HERMES_DESKTOP", "1")
with self._client():
assert called.wait(3.0), "expected cron tick under HERMES_DESKTOP=1"
def test_ticker_skipped_without_desktop(self, monkeypatch, _isolate_hermes_home):
import threading
import cron.scheduler as sched
called = threading.Event()
monkeypatch.setattr(sched, "tick", lambda *a, **k: called.set())
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
with self._client():
assert not called.wait(0.5), "ticker must not run outside the desktop app"