From 471a5fc5c93e938729c17108e9faf38690b6f58b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 14:04:11 -0500 Subject: [PATCH] feat(desktop): make cron jobs the first-class sidebar entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the cron surface around jobs (not run sessions), following power-user patterns (GitHub Actions / Airflow / Dagu): master → detail → output. Sidebar "Cron jobs" section: - jobs with a state pip + live next-run countdown - click toggles an inline run-history peek; a run opens its chat (active run highlighted) - hover: trigger-now + manage (open the Cron page) - capped at 50 with a "50+" badge Cron page: de-nested from a collapse-in-row accordion to master/detail — job list + the selected job's schedule, actions, and run history. Backend: GET /api/cron/jobs/{id}/runs lists a job's run sessions. Share STATE_DOT/jobState across both surfaces; drop dead code/keys. --- .../app/chat/sidebar/cron-jobs-section.tsx | 337 ++++++++++++++++++ apps/desktop/src/app/chat/sidebar/index.tsx | 43 +-- .../src/app/cron/cron-job-actions-menu.tsx | 114 ------ apps/desktop/src/app/cron/index.tsx | 299 +++++++++++++--- apps/desktop/src/app/cron/job-state.ts | 20 ++ apps/desktop/src/app/desktop-controller.tsx | 79 +++- .../assistant-ui/tool-fallback-model.ts | 83 +++++ apps/desktop/src/hermes.ts | 9 + apps/desktop/src/i18n/en.ts | 5 + apps/desktop/src/i18n/ja.ts | 5 + apps/desktop/src/i18n/types.ts | 5 + apps/desktop/src/i18n/zh-hant.ts | 5 + apps/desktop/src/i18n/zh.ts | 5 + apps/desktop/src/store/cron.ts | 15 + apps/desktop/src/store/session.ts | 5 + hermes_cli/web_server.py | 47 +++ 16 files changed, 866 insertions(+), 210 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx delete mode 100644 apps/desktop/src/app/cron/cron-job-actions-menu.tsx create mode 100644 apps/desktop/src/app/cron/job-state.ts create mode 100644 apps/desktop/src/store/cron.ts 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 0000000000..a168b79eed --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -0,0 +1,337 @@ +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, 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 + +function jobLabel(job: CronJob): string { + const name = (job.name ?? '').trim() + + if (name) {return name} + + const prompt = (job.prompt ?? '').trim() + + if (prompt) {return prompt.length > 60 ? `${prompt.slice(0, 60)}…` : prompt} + + return job.id +} + +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 jobLabel(a).localeCompare(jobLabel(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 = jobLabel(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 26e808745a..ef1832837f 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -40,6 +40,7 @@ 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, @@ -81,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' @@ -226,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({ @@ -236,7 +240,9 @@ export function ChatSidebar({ onResumeSession, onDeleteSession, onArchiveSession, - onNewSessionInWorkspace + onNewSessionInWorkspace, + onManageCronJob, + onTriggerCronJob }: ChatSidebarProps) { const { t } = useI18n() const s = t.sidebar @@ -250,6 +256,7 @@ export function ChatSidebar({ 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) @@ -413,17 +420,6 @@ export function ChatSidebar({ return [...out.values()] }, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId]) - // Cron-job sessions are a fully independent list (fetched separately so they - // never consume the recents page budget). Scope them like recents and drop - // any that are pinned (pin wins) to avoid a double-listing. - const visibleCronSessions = useMemo(() => { - const scoped = showAllProfiles - ? cronSessions - : cronSessions.filter(s => normalizeProfileKey(s.profile) === profileScope) - - return scoped.filter(s => !pinnedRealIdSet.has(s.id)).sort((a, b) => sessionTime(b) - sessionTime(a)) - }, [cronSessions, showAllProfiles, profileScope, pinnedRealIdSet]) - const unpinnedAgentSessions = useMemo( () => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)), [sortedSessions, pinnedRealIdSet] @@ -502,8 +498,7 @@ export function ChatSidebar({ const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 - const showSessionSections = - showSessionSkeletons || sortedSessions.length > 0 || visibleCronSessions.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 @@ -781,23 +776,15 @@ export function ChatSidebar({ /> )} - {sidebarOpen && showSessionSections && !trimmedQuery && visibleCronSessions.length > 0 && ( - 0 && ( + setSidebarCronOpen(!cronOpen)} - onTogglePin={pinSession} + onTriggerJob={onTriggerCronJob} open={cronOpen} - pinned={false} - rootClassName="shrink-0 p-0 pb-1" - sessions={visibleCronSessions} - workingSessionIdSet={workingSessionIdSet} /> )} 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 2993a1c741..0000000000 --- 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 dcf852e6aa..c7da8b9189 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' @@ -19,15 +20,18 @@ 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, setCronFocusJobId } from '@/store/cron' import { notify, notifyError } from '@/store/notifications' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' @@ -35,7 +39,7 @@ 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, STATE_DOT } from './job-state' const DEFAULT_DELIVER = 'local' @@ -110,10 +114,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,16 +261,28 @@ 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, + ...props +}: CronViewProps) { const { t } = useI18n() const c = t.cron const [jobs, setJobs] = useState(null) 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) @@ -295,6 +307,22 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou void refresh() }, [refresh]) + // 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 || !jobs) {return} + + const match = jobs.find(job => job.id === focusJobId || jobName(job) === focusJobId) + + if (match) { + setSelectedJobId(match.id) + pendingScrollRef.current = match.id + } + + setCronFocusJobId(null) + }, [focusJobId, jobs]) + const visibleJobs = useMemo(() => { if (!jobs) { return [] @@ -303,6 +331,25 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou return 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 enabledCount = jobs?.filter(job => job.enabled).length ?? 0 const totalCount = jobs?.length ?? 0 @@ -420,12 +467,11 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou 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). */} -
+ // Master/detail: job list on the left, the selected job's schedule, + // actions, and run history on the right. Replaces the old accordion + // (collapse-in-row inside a modal) — fewer clicks, no nested toggles. +
+
{c.active(enabledCount, totalCount)} @@ -434,19 +480,32 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou {c.newCron}
-
- {visibleJobs.map(job => ( - setPendingDelete(job)} - onEdit={() => setEditor({ mode: 'edit', job })} - onPauseResume={() => void handlePauseResume(job)} - onTrigger={() => void handleTrigger(job)} - /> - ))} +
+
+ {visibleJobs.map(job => ( + setSelectedJobId(job.id)} + /> + ))} +
+
+ {selectedJob && ( + setPendingDelete(selectedJob)} + onEdit={() => setEditor({ mode: 'edit', job: selectedJob })} + onOpenSession={onOpenSession} + onPauseResume={() => void handlePauseResume(selectedJob)} + onTrigger={() => void handleTrigger(selectedJob)} + /> + )} +
)} @@ -481,12 +540,48 @@ 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,32 +590,27 @@ 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 ( -
- - -
- - event.stopPropagation()} - title={jobTitle(job)} - /> - +
+ + + + +
+
+ +
+
+ ) +} + +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 => ( + + ))} +
+ )}
) } 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 0000000000..10b90df6e7 --- /dev/null +++ b/apps/desktop/src/app/cron/job-state.ts @@ -0,0 +1,20 @@ +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') +} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 48f56b8077..f02824e292 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,6 +38,7 @@ import { $selectedStoredSessionId, $sessions, $workingSessionIds, + CRON_SECTION_LIMIT, mergeSessionPage, sessionPinId, setAwaitingResponse, @@ -72,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' @@ -103,9 +105,19 @@ const SettingsView = lazy(async () => ({ default: (await import('./settings')).S const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView })) // Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The -// section shows the most-recent jobs, not the full history (that lives in -// search), so this stays small and is fetched as a single bounded page. -const CRON_SECTION_LIMIT = 50 +// 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 @@ -231,20 +243,35 @@ export function DesktopController() { }, []) // Cron-job sessions as their own list (latest N). Independent of the recents - // page so the two never compete for slots. Cheap + bounded; refreshed - // alongside recents. + // 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(sessions) + 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 @@ -277,7 +304,8 @@ export function DesktopController() { } void refreshCronSessions() - }, [refreshCronSessions]) + void refreshCronJobs() + }, [refreshCronSessions, refreshCronJobs]) const loadMoreSessions = useCallback(() => { bumpSessionsLimit() @@ -592,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, @@ -632,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) + }} /> ) @@ -701,7 +757,10 @@ export function DesktopController() { {cronOpen && ( - + navigate(sessionRoute(sessionId))} + /> )} diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts index 442de93941..3618d8011f 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts @@ -90,6 +90,7 @@ const TOOL_META: Record = { }, 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 20a4c80511..ce50fe98da 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, @@ -495,6 +496,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 29650b2d5c..b1d9e52deb 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -953,6 +953,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', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 4b6b120a40..5caeb67d30 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1055,6 +1055,11 @@ export const ja = defineLocale({ emptyTitleSearch: '一致なし', last: '前回', next: '次回', + noRuns: 'まだ実行されていません', + manage: '管理', + showRuns: '実行履歴を表示', + hideRuns: '実行履歴を隠す', + runHistory: '実行履歴', actionsFor: title => `${title} のアクション`, actionsTitle: 'Cron ジョブのアクション', resume: '再開', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index c5495fe4a9..2d2f39e310 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -715,6 +715,11 @@ export interface Translations { emptyTitleSearch: string last: string next: string + noRuns: string + manage: string + showRuns: string + hideRuns: string + runHistory: string actionsFor: (title: string) => string actionsTitle: string resume: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 4051996d24..bfae1b3d37 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1022,6 +1022,11 @@ export const zhHant = defineLocale({ emptyTitleSearch: '無相符項目', last: '上次:', next: '下次:', + noRuns: '尚無執行', + manage: '管理', + showRuns: '顯示執行記錄', + hideRuns: '隱藏執行記錄', + runHistory: '執行記錄', actionsFor: title => `${title} 的動作`, actionsTitle: '排程工作動作', resume: '繼續', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 262dc9afa3..1701102e6a 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1100,6 +1100,11 @@ export const zh: Translations = { emptyTitleSearch: '无匹配项', last: '上次:', next: '下次:', + noRuns: '尚无运行', + manage: '管理', + showRuns: '显示运行记录', + hideRuns: '隐藏运行记录', + runHistory: '运行记录', actionsFor: title => `${title} 的操作`, actionsTitle: '定时任务操作', resume: '恢复定时任务', diff --git a/apps/desktop/src/store/cron.ts b/apps/desktop/src/store/cron.ts new file mode 100644 index 0000000000..faa38472cc --- /dev/null +++ b/apps/desktop/src/store/cron.ts @@ -0,0 +1,15 @@ +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) + +// 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/session.ts b/apps/desktop/src/store/session.ts index 60f669a697..3dfcb7ff12 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -80,6 +80,11 @@ export const $sessionsTotal = atom(0) // 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 diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 6bf554a98f..2a0c279962 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5650,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: