diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 873712ae787..d874d7991d9 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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, diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx new file mode 100644 index 00000000000..7b0e7b95fe7 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -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) + + // 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 ( + +
+ +
+ {open && ( + + {shown.map(job => ( + onManageJob(job.id)} + onOpenRun={onOpenRun} + onTogglePeek={() => setPeekJobId(prev => (prev === job.id ? null : job.id))} + onTrigger={() => onTriggerJob(job.id)} + /> + ))} + + )} +
+ ) +} + +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 ( +
+
+ {/* 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. */} + + {/* Trailing cluster: countdown by default, quick actions on hover. */} +
+ + {meta} + +
+ + + + + + +
+
+
+ {expanded && } +
+ ) +} + +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) + + 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 ( +
+ {runs === null ? ( +
+ +
+ ) : runs.length === 0 ? ( +
{c.noRuns}
+ ) : ( + <> + {runs.map(run => ( + + ))} + + )} +
+ ) +} diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index d46948165dc..ef1832837f3 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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 { 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() - 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() @@ -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 && ( + setSidebarCronOpen(!cronOpen)} + onTriggerJob={onTriggerCronJob} + open={cronOpen} + /> + )} + {sidebarOpen && !showSessionSections &&
} {sidebarOpen && ( diff --git a/apps/desktop/src/app/cron/cron-job-actions-menu.tsx b/apps/desktop/src/app/cron/cron-job-actions-menu.tsx deleted file mode 100644 index 2993a1c7411..00000000000 --- a/apps/desktop/src/app/cron/cron-job-actions-menu.tsx +++ /dev/null @@ -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, '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 ( - - {children} - - { - triggerHaptic('selection') - onPauseResume() - }} - > - - {isPaused ? c.resumeTitle : c.pauseTitle} - - - { - triggerHaptic('selection') - onTrigger() - }} - > - - {c.triggerNow} - - - { - triggerHaptic('selection') - onEdit() - }} - > - - {c.edit} - - - { - triggerHaptic('warning') - onDelete() - }} - variant="destructive" - > - - {t.common.delete} - - - - ) -} - -interface CronJobActionsTriggerProps extends Omit, 'size' | 'variant'> { - title: string -} - -export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) { - const { t } = useI18n() - - return ( - - ) -} diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 075dd3380b7..459c3fd558f 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -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(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) + // Master/detail: the job whose schedule + run history fill the right pane. + const [selectedJobId, setSelectedJobId] = useState(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) + const focusJobId = useStore($cronFocusJobId) const [editor, setEditor] = useState({ mode: 'closed' }) const [pendingDelete, setPendingDelete] = useState(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 ( - void refresh()} - size="icon-xs" - title={refreshing ? c.refreshing : c.refresh} - type="button" - variant="ghost" - > - - - } - searchValue={query} - > - {!jobs ? ( - - ) : 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. - setEditor({ mode: 'create' }) : undefined} - title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch} - /> - ) : ( -
- {/* 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). */} -
- - {c.active(enabledCount, totalCount)} - - -
-
- {visibleJobs.map(job => ( - setPendingDelete(job)} - onEdit={() => setEditor({ mode: 'edit', job })} - onPauseResume={() => void handlePauseResume(job)} - onTrigger={() => void handleTrigger(job)} - /> - ))} -
-
- )} - setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> + {loading && jobs.length === 0 ? ( + + ) : ( + + + setEditor({ mode: 'create' })} /> + {totalCount > 0 && ( + + )} + {visibleJobs.map(job => ( + setSelectedJobId(job.id)} + /> + ))} + {visibleJobs.length === 0 && ( +

+ {totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch} +

+ )} +
+ + + {selectedJob ? ( + setPendingDelete(selectedJob)} + onEdit={() => setEditor({ mode: 'edit', job: selectedJob })} + onOpenSession={onOpenSession} + onPauseResume={() => void handlePauseResume(selectedJob)} + onTrigger={() => void handleTrigger(selectedJob)} + /> + ) : ( +
+
+ +

{totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}

+
+
+ )} +
+
+ )} + + setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}> @@ -476,17 +484,52 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou -
) } -function CronJobRow({ +function CronJobListRow({ + active, + c, + job, + onSelect +}: { + active: boolean + c: Translations['cron'] + job: CronJob + onSelect: () => void +}) { + const state = jobState(job) + + return ( + + ) +} + +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 ( -
- +
+
+
+
+
+
+
+

{jobTitle(job)}

+ {c.states[state] ?? state} + {deliver && deliver !== DEFAULT_DELIVER && ( + {c.deliveryLabels[deliver] ?? deliver} + )} +
+
+ + + {jobScheduleDisplay(job)} + + + {c.last} {formatTime(job.last_run_at)} + + + {c.next} {formatTime(job.next_run_at)} + +
+
+
+ + + + +
+
-
- - event.stopPropagation()} - title={jobTitle(job)} - /> - + {prompt &&

{prompt}

} + {job.last_error && ( +

+ + {job.last_error} +

+ )} +
+ + +
) } +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) + + 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 ( +
+
+ {c.runHistory} + {runs && runs.length > 0 ? ` · ${runs.length}` : ''} +
+ {runs === null ? ( +
+ +
+ ) : runs.length === 0 ? ( +
{c.noRuns}
+ ) : ( +
+ {runs.map(run => ( + + ))} +
+ )} +
+ ) +} + function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) { return ( void - title: string -}) { - return ( -
-
-
{title}
-

{description}

- {actionLabel && onAction && ( - - )} -
-
- ) -} - function CronEditorDialog({ editor, onClose, @@ -753,7 +870,7 @@ function CronEditorDialog({ {c.customHint} ) : ( -
+
{scheduleHint} {schedule} @@ -762,7 +879,7 @@ function CronEditorDialog({ )} {error && ( -
+
{error}
diff --git a/apps/desktop/src/app/cron/job-state.ts b/apps/desktop/src/app/cron/job-state.ts new file mode 100644 index 00000000000..b7dd139cc4e --- /dev/null +++ b/apps/desktop/src/app/cron/job-state.ts @@ -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 = { + 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' +} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index e6ef5cc64f3..f02824e2925 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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 && ( - + navigate(sessionRoute(sessionId))} + /> )} diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx index e713e4ea49e..fd562b40e28 100644 --- a/apps/desktop/src/app/overlays/overlay-split-layout.tsx +++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx @@ -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 ( + + ) +} + export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) { return ( + setCreateOpen(true)} /> {profiles.map(profile => ( = { }, 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, + resultRecord: Record +): 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, + resultRecord: Record +): 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, @@ -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) } diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index aac7c0acd2b..33aa9fea320 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -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 { + 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 { @@ -488,6 +505,14 @@ export function getCronJob(jobId: string): Promise { }) } +export async function getCronJobRuns(jobId: string, limit = 20): Promise { + 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 { return window.hermesDesktop.api({ path: '/api/cron/jobs', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 4dedd88cc52..29dd4ba864e 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 9d6e752f058..65a2bf6591e 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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: 'セッションのグループ化を解除', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 11425046844..b72d9d8fd71 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -699,8 +699,6 @@ export interface Translations { cron: { close: string search: string - refresh: string - refreshing: string loading: string states: Record deliveryLabels: Record @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 6d17cada126..76a0ea69643 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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: '取消分組', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 4b228e7ab60..d091e505586 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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: '取消分组', diff --git a/apps/desktop/src/store/cron.ts b/apps/desktop/src/store/cron.ts new file mode 100644 index 00000000000..2c492b34908 --- /dev/null +++ b/apps/desktop/src/store/cron.ts @@ -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([]) +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) +export const setCronFocusJobId = (id: null | string) => $cronFocusJobId.set(id) 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..3dfcb7ff12b 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -76,6 +76,15 @@ 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([]) +// 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) => 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/cron/scheduler.py b/cron/scheduler.py index 38b7b95ab7f..f5c71ceed4f 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -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: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 95cfd34fc14..2a0c279962d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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: 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) diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 432beb764f9..8056d3c49e2 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -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 diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 60d2b7b5c18..bb3085eff22 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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"