mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
Merge pull request #40684 from NousResearch/bb/cron-sessions-sidebar
feat(desktop): first-class cron jobs in the sidebar + dashboard scheduler
This commit is contained in:
commit
846821d8c0
24 changed files with 1219 additions and 354 deletions
|
|
@ -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,
|
||||
|
|
|
|||
325
apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx
Normal file
325
apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getCronJobRuns, type SessionInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $selectedStoredSessionId } from '@/store/session'
|
||||
import type { CronJob } from '@/types/hermes'
|
||||
|
||||
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
|
||||
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
|
||||
|
||||
// Recent runs shown in the inline quick-peek — enough to glance at history
|
||||
// without turning the sidebar into the full Cron page.
|
||||
const PEEK_RUN_LIMIT = 5
|
||||
|
||||
// Runs are written by the background scheduler tick (no UI signal), so poll the
|
||||
// open peek so a freshly-fired run shows up within a few seconds.
|
||||
const PEEK_POLL_INTERVAL_MS = 8000
|
||||
|
||||
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
|
||||
|
||||
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
|
||||
// coarsest sensible unit so a daily job reads "in 14 hr", not "in 840 min".
|
||||
function relativeTime(targetMs: number, nowMs: number): string {
|
||||
const diff = targetMs - nowMs
|
||||
const abs = Math.abs(diff)
|
||||
const sign = diff < 0 ? -1 : 1
|
||||
|
||||
if (abs < 60_000) {return relativeFmt.format(sign * Math.round(abs / 1000), 'second')}
|
||||
|
||||
if (abs < 3_600_000) {return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')}
|
||||
|
||||
if (abs < 86_400_000) {return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')}
|
||||
|
||||
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
|
||||
}
|
||||
|
||||
function nextRunMs(job: CronJob): null | number {
|
||||
if (!job.next_run_at) {return null}
|
||||
|
||||
const ms = Date.parse(job.next_run_at)
|
||||
|
||||
return Number.isNaN(ms) ? null : ms
|
||||
}
|
||||
|
||||
// Runs all belong to the same job, so the run name just repeats the job name —
|
||||
// the timestamp is what tells them apart. Compact (no year, no seconds) for the
|
||||
// narrow sidebar.
|
||||
function formatRunTime(seconds?: null | number): string {
|
||||
if (!seconds) {return '—'}
|
||||
|
||||
const date = new Date(seconds * 1000)
|
||||
|
||||
return Number.isNaN(date.valueOf())
|
||||
? '—'
|
||||
: date.toLocaleString(undefined, { day: 'numeric', hour: 'numeric', minute: '2-digit', month: 'short' })
|
||||
}
|
||||
|
||||
interface SidebarCronJobsSectionProps {
|
||||
jobs: CronJob[]
|
||||
label: string
|
||||
max?: number
|
||||
// Open a run session's chat (1 click to output).
|
||||
onOpenRun: (sessionId: string) => void
|
||||
// Open the full Cron page focused on this job (manage / full history).
|
||||
onManageJob: (jobId: string) => void
|
||||
// Fire the job now.
|
||||
onTriggerJob: (jobId: string) => void
|
||||
onToggle: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function SidebarCronJobsSection({
|
||||
jobs,
|
||||
label,
|
||||
max = 50,
|
||||
onManageJob,
|
||||
onOpenRun,
|
||||
onTriggerJob,
|
||||
onToggle,
|
||||
open
|
||||
}: SidebarCronJobsSectionProps) {
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
// Single-open inline peek so the section stays scannable.
|
||||
const [peekJobId, setPeekJobId] = useState<null | string>(null)
|
||||
|
||||
// One clock for the whole section (rows are pure) so the countdowns tick
|
||||
// without re-rendering the rest of the sidebar. Only runs while expanded.
|
||||
useEffect(() => {
|
||||
if (!open) {return}
|
||||
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [open])
|
||||
|
||||
// Upcoming first (soonest next run), jobs with no next run sink to the bottom,
|
||||
// then alphabetical for stability.
|
||||
const sorted = useMemo(() => {
|
||||
return [...jobs].sort((a, b) => {
|
||||
const an = nextRunMs(a)
|
||||
const bn = nextRunMs(b)
|
||||
|
||||
if (an !== null && bn !== null && an !== bn) {return an - bn}
|
||||
|
||||
if (an === null && bn !== null) {return 1}
|
||||
|
||||
if (an !== null && bn === null) {return -1}
|
||||
|
||||
return jobTitle(a).localeCompare(jobTitle(b))
|
||||
})
|
||||
}, [jobs])
|
||||
|
||||
const shown = sorted.slice(0, max)
|
||||
// When capped, signal "50+" rather than implying the list is complete.
|
||||
const countLabel = jobs.length > max ? `${max}+` : String(jobs.length)
|
||||
|
||||
return (
|
||||
<SidebarGroup className="shrink-0 p-0 pb-1">
|
||||
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
||||
<button
|
||||
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{label}</SidebarPanelLabel>
|
||||
<span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{countLabel}</span>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/section-label:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<SidebarGroupContent className="flex max-h-72 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
|
||||
{shown.map(job => (
|
||||
<CronJobSidebarRow
|
||||
expanded={peekJobId === job.id}
|
||||
job={job}
|
||||
key={job.id}
|
||||
nowMs={nowMs}
|
||||
onManage={() => onManageJob(job.id)}
|
||||
onOpenRun={onOpenRun}
|
||||
onTogglePeek={() => setPeekJobId(prev => (prev === job.id ? null : job.id))}
|
||||
onTrigger={() => onTriggerJob(job.id)}
|
||||
/>
|
||||
))}
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobSidebarRow({
|
||||
expanded,
|
||||
job,
|
||||
nowMs,
|
||||
onManage,
|
||||
onOpenRun,
|
||||
onTogglePeek,
|
||||
onTrigger
|
||||
}: {
|
||||
expanded: boolean
|
||||
job: CronJob
|
||||
nowMs: number
|
||||
onManage: () => void
|
||||
onOpenRun: (sessionId: string) => void
|
||||
onTogglePeek: () => void
|
||||
onTrigger: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
const state = jobState(job)
|
||||
const next = nextRunMs(job)
|
||||
const label = jobTitle(job)
|
||||
|
||||
const meta = INACTIVE_STATES.has(state)
|
||||
? (c.states[state] ?? state)
|
||||
: next !== null
|
||||
? relativeTime(next, nowMs)
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="group/cron relative grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_auto] items-center rounded-md hover:bg-(--chrome-action-hover)">
|
||||
{/* Lead with the dot in the same w-3.5 cell + pl-2 the session rows use
|
||||
so the cron dots line up with the sessions above; the caret sits next
|
||||
to the label (matching the other sidebar disclosures) and the whole
|
||||
label area toggles the run peek. */}
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? c.hideRuns : c.showRuns}
|
||||
className="flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
onClick={onTogglePeek}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid w-3.5 shrink-0 place-items-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-1 rounded-full',
|
||||
STATE_DOT[state] ?? 'bg-(--ui-text-quaternary)',
|
||||
state === 'running' && 'size-1.5 animate-pulse'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[0.8125rem] text-(--ui-text-secondary) group-hover/cron:text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<DisclosureCaret
|
||||
className={cn(
|
||||
'shrink-0 text-(--ui-text-tertiary) transition',
|
||||
expanded ? 'opacity-100' : 'opacity-0 group-hover/cron:opacity-100'
|
||||
)}
|
||||
open={expanded}
|
||||
/>
|
||||
</button>
|
||||
{/* Trailing cluster: countdown by default, quick actions on hover. */}
|
||||
<div className="flex items-center gap-0.5 justify-self-end pr-1">
|
||||
<span className="text-[0.6875rem] text-(--ui-text-tertiary) tabular-nums group-hover/cron:hidden">
|
||||
{meta}
|
||||
</span>
|
||||
<div className="hidden items-center gap-0.5 group-hover/cron:flex">
|
||||
<Tip label={c.triggerNow}>
|
||||
<button
|
||||
aria-label={c.triggerNow}
|
||||
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={onTrigger}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="zap" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
<Tip label={c.manage}>
|
||||
<button
|
||||
aria-label={c.manage}
|
||||
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={onManage}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="watch" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && <CronJobSidebarRuns jobId={job.id} onOpenRun={onOpenRun} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobSidebarRuns({
|
||||
jobId,
|
||||
onOpenRun
|
||||
}: {
|
||||
jobId: string
|
||||
onOpenRun: (sessionId: string) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = () =>
|
||||
getCronJobRuns(jobId, PEEK_RUN_LIMIT)
|
||||
.then(result => {
|
||||
if (!cancelled) {setRuns(result)}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {setRuns(prev => prev ?? [])}
|
||||
})
|
||||
|
||||
void load()
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {void load()}
|
||||
}, PEEK_POLL_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}, [jobId])
|
||||
|
||||
return (
|
||||
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">
|
||||
{runs === null ? (
|
||||
<div className="flex items-center gap-1.5 py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">{c.noRuns}</div>
|
||||
) : (
|
||||
<>
|
||||
{runs.map(run => (
|
||||
<button
|
||||
className={cn(
|
||||
'truncate rounded-md px-1.5 py-0.5 text-left text-[0.6875rem] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
|
||||
run.id === selectedSessionId
|
||||
? 'bg-(--ui-row-active-background) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
key={run.id}
|
||||
onClick={() => onOpenRun(run.id)}
|
||||
type="button"
|
||||
>
|
||||
{formatRunTime(run.last_active || run.started_at)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -40,10 +40,12 @@ import { useI18n } from '@/i18n'
|
|||
import { profileColor } from '@/lib/profile-color'
|
||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $cronJobs } from '@/store/cron'
|
||||
import {
|
||||
$panesFlipped,
|
||||
$pinnedSessionIds,
|
||||
$sidebarAgentsGrouped,
|
||||
$sidebarCronOpen,
|
||||
$sidebarOpen,
|
||||
$sidebarPinsOpen,
|
||||
$sidebarRecentsOpen,
|
||||
|
|
@ -51,6 +53,7 @@ import {
|
|||
reorderPinnedSession,
|
||||
SESSION_SEARCH_FOCUS_EVENT,
|
||||
setSidebarAgentsGrouped,
|
||||
setSidebarCronOpen,
|
||||
setSidebarPinsOpen,
|
||||
setSidebarRecentsOpen,
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
|
|
@ -65,6 +68,7 @@ import {
|
|||
normalizeProfileKey
|
||||
} from '@/store/profile'
|
||||
import {
|
||||
$cronSessions,
|
||||
$selectedStoredSessionId,
|
||||
$sessionProfileTotals,
|
||||
$sessions,
|
||||
|
|
@ -78,6 +82,7 @@ import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '..
|
|||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { SidebarCronJobsSection } from './cron-jobs-section'
|
||||
import { ProfileRail } from './profile-switcher'
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
import { VirtualSessionList } from './virtual-session-list'
|
||||
|
|
@ -223,6 +228,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
|||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onNewSessionInWorkspace: (path: null | string) => void
|
||||
onManageCronJob: (jobId: string) => void
|
||||
onTriggerCronJob: (jobId: string) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
|
|
@ -233,7 +240,9 @@ export function ChatSidebar({
|
|||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
onNewSessionInWorkspace
|
||||
onNewSessionInWorkspace,
|
||||
onManageCronJob,
|
||||
onTriggerCronJob
|
||||
}: ChatSidebarProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
|
|
@ -243,8 +252,11 @@ export function ChatSidebar({
|
|||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const pinsOpen = useStore($sidebarPinsOpen)
|
||||
const agentsOpen = useStore($sidebarRecentsOpen)
|
||||
const cronOpen = useStore($sidebarCronOpen)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const sessions = useStore($sessions)
|
||||
const cronSessions = useStore($cronSessions)
|
||||
const cronJobs = useStore($cronJobs)
|
||||
const sessionsLoading = useStore($sessionsLoading)
|
||||
const sessionsTotal = useStore($sessionsTotal)
|
||||
const sessionProfileTotals = useStore($sessionProfileTotals)
|
||||
|
|
@ -323,7 +335,10 @@ export function ChatSidebar({
|
|||
const sessionByAnyId = useMemo(() => {
|
||||
const map = new Map<string, SessionInfo>()
|
||||
|
||||
for (const s of visibleSessions) {
|
||||
// Cron sessions are listed separately but can still be pinned, so index
|
||||
// them too — otherwise a pinned cron job can't resolve into the Pinned
|
||||
// section. Recents take precedence on id collisions (set last).
|
||||
for (const s of [...cronSessions, ...visibleSessions]) {
|
||||
map.set(s.id, s)
|
||||
|
||||
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
|
||||
|
|
@ -332,7 +347,7 @@ export function ChatSidebar({
|
|||
}
|
||||
|
||||
return map
|
||||
}, [visibleSessions])
|
||||
}, [visibleSessions, cronSessions])
|
||||
|
||||
const pinnedSessions = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
|
|
@ -482,7 +497,9 @@ export function ChatSidebar({
|
|||
])
|
||||
|
||||
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
|
||||
|
||||
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
|
||||
|
||||
// Pagination is scope-aware. In "All profiles" mode it tracks the global
|
||||
// unified set. When scoped to one profile it must compare that profile's own
|
||||
// loaded rows against that profile's total — otherwise a huge default profile
|
||||
|
|
@ -759,6 +776,18 @@ export function ChatSidebar({
|
|||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && !trimmedQuery && cronJobs.length > 0 && (
|
||||
<SidebarCronJobsSection
|
||||
jobs={cronJobs}
|
||||
label={s.cronJobs}
|
||||
onManageJob={onManageCronJob}
|
||||
onOpenRun={onResumeSession}
|
||||
onToggle={() => setSidebarCronOpen(!cronOpen)}
|
||||
onTriggerJob={onTriggerCronJob}
|
||||
open={cronOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
|
||||
|
||||
{sidebarOpen && (
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
||||
interface CronJobActions {
|
||||
busy?: boolean
|
||||
isPaused: boolean
|
||||
title: string
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
onPauseResume: () => void
|
||||
onTrigger: () => void
|
||||
}
|
||||
|
||||
interface CronJobActionsMenuProps
|
||||
extends CronJobActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function CronJobActionsMenu({
|
||||
align = 'end',
|
||||
busy = false,
|
||||
children,
|
||||
isPaused,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onPauseResume,
|
||||
onTrigger,
|
||||
sideOffset = 6,
|
||||
title
|
||||
}: CronJobActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={c.actionsFor(title)}
|
||||
className="w-44"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={busy}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onPauseResume()
|
||||
}}
|
||||
>
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
<span>{isPaused ? c.resumeTitle : c.pauseTitle}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={busy}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onTrigger()
|
||||
}}
|
||||
>
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
<span>{c.triggerNow}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>{c.edit}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
triggerHaptic('warning')
|
||||
onDelete()
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>{t.common.delete}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
|
||||
title: string
|
||||
}
|
||||
|
||||
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={t.cron.actionsFor(title)}
|
||||
className={className}
|
||||
size="icon-sm"
|
||||
title={t.cron.actionsTitle}
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<Codicon className="text-muted-foreground" name="ellipsis" size="0.875rem" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -13,29 +14,33 @@ import {
|
|||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
createCronJob,
|
||||
type CronJob,
|
||||
deleteCronJob,
|
||||
getCronJobRuns,
|
||||
getCronJobs,
|
||||
pauseCronJob,
|
||||
resumeCronJob,
|
||||
type SessionInfo,
|
||||
triggerCronJob,
|
||||
updateCronJob
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Clock } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
|
||||
import { jobState, jobTitle, STATE_DOT } from './job-state'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
|
|
@ -80,28 +85,6 @@ function jobPrompt(job: CronJob): string {
|
|||
return asText(job.prompt)
|
||||
}
|
||||
|
||||
function jobTitle(job: CronJob): string {
|
||||
const name = jobName(job)
|
||||
|
||||
if (name) {
|
||||
return name
|
||||
}
|
||||
|
||||
const prompt = jobPrompt(job)
|
||||
|
||||
if (prompt) {
|
||||
return truncate(prompt, 60)
|
||||
}
|
||||
|
||||
const script = asText(job.script)
|
||||
|
||||
if (script) {
|
||||
return truncate(script, 60)
|
||||
}
|
||||
|
||||
return job.id || 'Cron job'
|
||||
}
|
||||
|
||||
function jobScheduleDisplay(job: CronJob): string {
|
||||
return asText(job.schedule_display) || asText(job.schedule?.display) || asText(job.schedule?.expr) || '—'
|
||||
}
|
||||
|
|
@ -110,10 +93,6 @@ function jobScheduleExpr(job: CronJob): string {
|
|||
return asText(job.schedule?.expr) || asText(job.schedule_display) || ''
|
||||
}
|
||||
|
||||
function jobState(job: CronJob): string {
|
||||
return asText(job.state) || (job.enabled === false ? 'disabled' : 'scheduled')
|
||||
}
|
||||
|
||||
function jobDeliver(job: CronJob): string {
|
||||
return asText(job.deliver) || DEFAULT_DELIVER
|
||||
}
|
||||
|
|
@ -261,31 +240,38 @@ function matchesQuery(job: CronJob, q: string): boolean {
|
|||
|
||||
interface CronViewProps extends React.ComponentProps<'section'> {
|
||||
onClose: () => void
|
||||
onOpenSession?: (sessionId: string) => void
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
|
||||
export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
|
||||
export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setStatusbarItemGroup }: CronViewProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
const [jobs, setJobs] = useState<CronJob[] | null>(null)
|
||||
// Source of truth is the shared atom (also fed by the controller poll), so the
|
||||
// sidebar and this overlay never drift — a delete here clears the sidebar row
|
||||
// immediately. `loading` only gates the first paint before the atom is filled.
|
||||
const jobs = useStore($cronJobs)
|
||||
const [loading, setLoading] = useState(jobs.length === 0)
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyJobId, setBusyJobId] = useState<null | string>(null)
|
||||
// Master/detail: the job whose schedule + run history fill the right pane.
|
||||
const [selectedJobId, setSelectedJobId] = useState<null | string>(null)
|
||||
// Set when a job is opened from the sidebar so we scroll it into view once the
|
||||
// row exists. Cleared after the scroll fires.
|
||||
const pendingScrollRef = useRef<null | string>(null)
|
||||
const focusJobId = useStore($cronFocusJobId)
|
||||
|
||||
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
|
||||
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const result = await getCronJobs()
|
||||
setJobs(result)
|
||||
setCronJobs(await getCronJobs())
|
||||
} catch (err) {
|
||||
notifyError(err, c.failedLoad)
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
setLoading(false)
|
||||
}
|
||||
}, [c])
|
||||
|
||||
|
|
@ -295,16 +281,47 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
|||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
const visibleJobs = useMemo(() => {
|
||||
if (!jobs) {
|
||||
return []
|
||||
// Sidebar → "open this job": resolve the focus id (or name) to a job, select
|
||||
// it, queue a scroll, then clear the one-shot focus so re-opening cron
|
||||
// normally doesn't re-trigger it.
|
||||
useEffect(() => {
|
||||
if (!focusJobId) {return}
|
||||
|
||||
const match = jobs.find(job => job.id === focusJobId || jobName(job) === focusJobId)
|
||||
|
||||
if (match) {
|
||||
setSelectedJobId(match.id)
|
||||
pendingScrollRef.current = match.id
|
||||
}
|
||||
|
||||
return jobs.filter(job => matchesQuery(job, query.trim())).sort((a, b) => jobTitle(a).localeCompare(jobTitle(b)))
|
||||
}, [jobs, query])
|
||||
setCronFocusJobId(null)
|
||||
}, [focusJobId, jobs])
|
||||
|
||||
const enabledCount = jobs?.filter(job => job.enabled).length ?? 0
|
||||
const totalCount = jobs?.length ?? 0
|
||||
const visibleJobs = useMemo(
|
||||
() => jobs.filter(job => matchesQuery(job, query.trim())).sort((a, b) => jobTitle(a).localeCompare(jobTitle(b))),
|
||||
[jobs, query]
|
||||
)
|
||||
|
||||
// Detail always reflects a concrete job: the explicitly selected one, else the
|
||||
// first visible row, so the right pane is never empty while jobs exist.
|
||||
const selectedJob = useMemo(
|
||||
() => visibleJobs.find(job => job.id === selectedJobId) ?? visibleJobs[0] ?? null,
|
||||
[visibleJobs, selectedJobId]
|
||||
)
|
||||
|
||||
// Scroll a sidebar-opened job into view once its list row is mounted.
|
||||
useEffect(() => {
|
||||
const target = pendingScrollRef.current
|
||||
|
||||
if (!target || selectedJob?.id !== target) {return}
|
||||
|
||||
pendingScrollRef.current = null
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector(`[data-cron-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}, [selectedJob])
|
||||
|
||||
const totalCount = jobs.length
|
||||
|
||||
async function handlePauseResume(job: CronJob) {
|
||||
setBusyJobId(job.id)
|
||||
|
|
@ -312,7 +329,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
|||
try {
|
||||
const isPaused = jobState(job) === 'paused'
|
||||
const updated = isPaused ? await resumeCronJob(job.id) : await pauseCronJob(job.id)
|
||||
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
|
||||
updateCronJobs(rows => rows.map(row => (row.id === job.id ? updated : row)))
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: isPaused ? c.resumed : c.paused,
|
||||
|
|
@ -330,7 +347,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
|||
|
||||
try {
|
||||
const updated = await triggerCronJob(job.id)
|
||||
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
|
||||
updateCronJobs(rows => rows.map(row => (row.id === job.id ? updated : row)))
|
||||
notify({ kind: 'success', title: c.triggered, message: truncate(jobTitle(job), 60) })
|
||||
} catch (err) {
|
||||
notifyError(err, c.failedTrigger)
|
||||
|
|
@ -348,7 +365,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
|||
|
||||
try {
|
||||
await deleteCronJob(pendingDelete.id)
|
||||
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
|
||||
updateCronJobs(rows => rows.filter(row => row.id !== pendingDelete.id))
|
||||
notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) })
|
||||
setPendingDelete(null)
|
||||
} catch (err) {
|
||||
|
|
@ -367,7 +384,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
|||
deliver: values.deliver || DEFAULT_DELIVER
|
||||
})
|
||||
|
||||
setJobs(current => (current ? [...current, created] : [created]))
|
||||
updateCronJobs(rows => [...rows, created])
|
||||
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
|
||||
} else if (editor.mode === 'edit') {
|
||||
const updated = await updateCronJob(editor.job.id, {
|
||||
|
|
@ -377,7 +394,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
|||
deliver: values.deliver
|
||||
})
|
||||
|
||||
setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current))
|
||||
updateCronJobs(rows => rows.map(row => (row.id === updated.id ? updated : row)))
|
||||
notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) })
|
||||
}
|
||||
|
||||
|
|
@ -386,71 +403,62 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
|||
|
||||
return (
|
||||
<OverlayView closeLabel={c.close} onClose={onClose}>
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder={c.search}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? c.refreshing : c.refresh}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refresh()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? c.refreshing : c.refresh}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
>
|
||||
{!jobs ? (
|
||||
<PageLoader label={c.loading} />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
// Empty state owns the primary "create" CTA — we used to also have
|
||||
// one in the filters bar but it was redundant. Only show the button
|
||||
// when there are zero jobs total; the search-empty case ("No
|
||||
// matches") just asks the user to broaden their query.
|
||||
<EmptyState
|
||||
actionLabel={totalCount === 0 ? c.createFirst : undefined}
|
||||
description={totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}
|
||||
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
|
||||
title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{/* Inline header replaces the old top-bar "New cron" button. We
|
||||
still need a single, always-visible affordance to add a job
|
||||
when the list is non-empty (rows themselves only expose
|
||||
edit/pause/trigger/delete). */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
|
||||
{c.active(enabledCount, totalCount)}
|
||||
</span>
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
{c.newCron}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="divide-y divide-(--ui-stroke-tertiary)">
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
busy={busyJobId === job.id}
|
||||
c={c}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onDelete={() => setPendingDelete(job)}
|
||||
onEdit={() => setEditor({ mode: 'edit', job })}
|
||||
onPauseResume={() => void handlePauseResume(job)}
|
||||
onTrigger={() => void handleTrigger(job)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
{loading && jobs.length === 0 ? (
|
||||
<PageLoader label={c.loading} />
|
||||
) : (
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
<OverlayNewButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
|
||||
{totalCount > 0 && (
|
||||
<SearchField
|
||||
aria-label={c.search}
|
||||
containerClassName="mb-1 w-full px-2"
|
||||
onChange={setQuery}
|
||||
placeholder={c.search}
|
||||
value={query}
|
||||
/>
|
||||
)}
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobListRow
|
||||
active={selectedJob?.id === job.id}
|
||||
c={c}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onSelect={() => setSelectedJobId(job.id)}
|
||||
/>
|
||||
))}
|
||||
{visibleJobs.length === 0 && (
|
||||
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
{totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
|
||||
</p>
|
||||
)}
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain className="px-0">
|
||||
{selectedJob ? (
|
||||
<CronJobDetail
|
||||
busy={busyJobId === selectedJob.id}
|
||||
c={c}
|
||||
job={selectedJob}
|
||||
onDelete={() => setPendingDelete(selectedJob)}
|
||||
onEdit={() => setEditor({ mode: 'edit', job: selectedJob })}
|
||||
onOpenSession={onOpenSession}
|
||||
onPauseResume={() => void handlePauseResume(selectedJob)}
|
||||
onTrigger={() => void handleTrigger(selectedJob)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
<div>
|
||||
<Clock className="mx-auto size-6 text-muted-foreground/60" />
|
||||
<p className="mt-3">{totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
)}
|
||||
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<DialogContent className="max-w-md">
|
||||
|
|
@ -476,17 +484,52 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageSearchShell>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobRow({
|
||||
function CronJobListRow({
|
||||
active,
|
||||
c,
|
||||
job,
|
||||
onSelect
|
||||
}: {
|
||||
active: boolean
|
||||
c: Translations['cron']
|
||||
job: CronJob
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const state = jobState(job)
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
|
||||
)}
|
||||
data-cron-row={job.id}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex w-full items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('size-1.5 shrink-0 rounded-full', STATE_DOT[state] ?? 'bg-muted-foreground')}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{jobTitle(job)}</span>
|
||||
</span>
|
||||
<span className="truncate pl-3.5 text-[0.66rem] text-muted-foreground">{jobScheduleDisplay(job)}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobDetail({
|
||||
busy,
|
||||
c,
|
||||
job,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onOpenSession,
|
||||
onPauseResume,
|
||||
onTrigger
|
||||
}: {
|
||||
|
|
@ -495,71 +538,172 @@ function CronJobRow({
|
|||
job: CronJob
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
onOpenSession?: (sessionId: string) => void
|
||||
onPauseResume: () => void
|
||||
onTrigger: () => void
|
||||
}) {
|
||||
const state = jobState(job)
|
||||
const isPaused = state === 'paused'
|
||||
const hasName = Boolean(jobName(job))
|
||||
const prompt = jobPrompt(job)
|
||||
const deliver = jobDeliver(job)
|
||||
const prompt = jobPrompt(job)
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
|
||||
<button
|
||||
className="min-w-0 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{jobTitle(job)}</span>
|
||||
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
|
||||
{deliver && deliver !== DEFAULT_DELIVER && (
|
||||
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
|
||||
)}
|
||||
</div>
|
||||
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.68rem] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1 font-mono">
|
||||
<Clock className="size-3" />
|
||||
{jobScheduleDisplay(job)}
|
||||
</span>
|
||||
<span>
|
||||
{c.last} {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
<span>
|
||||
{c.next} {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
</div>
|
||||
{job.last_error && (
|
||||
<p className="mt-1 inline-flex items-start gap-1 text-[0.68rem] text-destructive">
|
||||
<AlertTriangle className="mt-px size-3 shrink-0" />
|
||||
<span className="line-clamp-2">{job.last_error}</span>
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||
<header className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{jobTitle(job)}</h3>
|
||||
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
|
||||
{deliver && deliver !== DEFAULT_DELIVER && (
|
||||
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.7rem] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{jobScheduleDisplay(job)}
|
||||
</span>
|
||||
<span>
|
||||
{c.last} {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
<span>
|
||||
{c.next} {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button disabled={busy} onClick={onPauseResume} size="sm" variant="outline">
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
{isPaused ? c.resumeTitle : c.pauseTitle}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={onTrigger} size="sm" variant="outline">
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
{c.triggerNow}
|
||||
</Button>
|
||||
<Button onClick={onEdit} size="sm" variant="outline">
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
{c.edit}
|
||||
</Button>
|
||||
<Button
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
<CronJobActionsMenu
|
||||
busy={busy}
|
||||
isPaused={isPaused}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onPauseResume={onPauseResume}
|
||||
onTrigger={onTrigger}
|
||||
title={jobTitle(job)}
|
||||
>
|
||||
<CronJobActionsTrigger
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={event => event.stopPropagation()}
|
||||
title={jobTitle(job)}
|
||||
/>
|
||||
</CronJobActionsMenu>
|
||||
{prompt && <p className="line-clamp-3 text-xs text-muted-foreground">{prompt}</p>}
|
||||
{job.last_error && (
|
||||
<p className="inline-flex items-start gap-1 text-[0.7rem] text-destructive">
|
||||
<AlertTriangle className="mt-px size-3 shrink-0" />
|
||||
<span className="line-clamp-2">{job.last_error}</span>
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<CronJobRuns c={c} jobId={job.id} onOpenSession={onOpenSession} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRunTime(seconds?: null | number): string {
|
||||
if (!seconds) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
const date = new Date(seconds * 1000)
|
||||
|
||||
return Number.isNaN(date.valueOf()) ? '—' : date.toLocaleString()
|
||||
}
|
||||
|
||||
// Runs are produced by the background scheduler tick (no UI signal), so poll
|
||||
// while the panel is open + on tab re-focus so a fired run shows up within a few
|
||||
// seconds instead of waiting for a reload.
|
||||
const RUNS_POLL_INTERVAL_MS = 8000
|
||||
|
||||
function CronJobRuns({
|
||||
c,
|
||||
jobId,
|
||||
onOpenSession
|
||||
}: {
|
||||
c: Translations['cron']
|
||||
jobId: string
|
||||
onOpenSession?: (sessionId: string) => void
|
||||
}) {
|
||||
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = () =>
|
||||
getCronJobRuns(jobId)
|
||||
.then(result => {
|
||||
if (!cancelled) {setRuns(result)}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {setRuns(prev => prev ?? [])}
|
||||
})
|
||||
|
||||
void load()
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {void load()}
|
||||
}, RUNS_POLL_INTERVAL_MS)
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') {void load()}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(intervalId)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
}, [jobId])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[0.62rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{c.runHistory}
|
||||
{runs && runs.length > 0 ? ` · ${runs.length}` : ''}
|
||||
</div>
|
||||
{runs === null ? (
|
||||
<div className="flex items-center gap-1.5 py-1 text-xs text-muted-foreground">
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="py-1 text-xs text-muted-foreground">{c.noRuns}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-px">
|
||||
{runs.map(run => (
|
||||
<button
|
||||
className="flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs hover:bg-(--chrome-action-hover) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
key={run.id}
|
||||
onClick={() => onOpenSession?.(run.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate text-foreground">{run.title?.trim() || run.preview?.trim() || run.id}</span>
|
||||
<span className="shrink-0 text-[0.62rem] text-muted-foreground tabular-nums">
|
||||
{formatRunTime(run.last_active || run.started_at)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
|
||||
return (
|
||||
<span
|
||||
|
|
@ -570,33 +714,6 @@ function StatePill({ children, tone }: { children: string; tone: keyof typeof PI
|
|||
)
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
actionLabel,
|
||||
description,
|
||||
onAction,
|
||||
title
|
||||
}: {
|
||||
actionLabel?: string
|
||||
description: string
|
||||
onAction?: () => void
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center px-6 py-12 text-center">
|
||||
<div className="max-w-sm space-y-2">
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
{actionLabel && onAction && (
|
||||
<Button className="mt-2" onClick={onAction} size="sm">
|
||||
<Codicon name="add" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CronEditorDialog({
|
||||
editor,
|
||||
onClose,
|
||||
|
|
@ -753,7 +870,7 @@ function CronEditorDialog({
|
|||
<FieldHint>{c.customHint}</FieldHint>
|
||||
</Field>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2">
|
||||
<div className="rounded-md bg-(--ui-bg-quinary) px-3 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<span className="font-medium text-foreground">{scheduleHint}</span>
|
||||
<span className="font-mono text-muted-foreground">{schedule}</span>
|
||||
|
|
@ -762,7 +879,7 @@ function CronEditorDialog({
|
|||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
|
|
|
|||
29
apps/desktop/src/app/cron/job-state.ts
Normal file
29
apps/desktop/src/app/cron/job-state.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { CronJob } from '@/types/hermes'
|
||||
|
||||
// Status-pip color per cron job state. Single source for the sidebar section and
|
||||
// the Cron page so the two never drift. (Animation/size live at the call site.)
|
||||
export const STATE_DOT: Record<string, string> = {
|
||||
completed: 'bg-(--ui-text-quaternary)',
|
||||
disabled: 'bg-(--ui-text-quaternary)',
|
||||
enabled: 'bg-primary',
|
||||
error: 'bg-destructive',
|
||||
paused: 'bg-amber-500',
|
||||
running: 'bg-primary',
|
||||
scheduled: 'bg-primary'
|
||||
}
|
||||
|
||||
// Effective state: explicit state wins; otherwise infer from the enabled flag.
|
||||
export function jobState(job: CronJob): string {
|
||||
const state = typeof job.state === 'string' ? job.state.trim() : ''
|
||||
|
||||
return state || (job.enabled === false ? 'disabled' : 'scheduled')
|
||||
}
|
||||
|
||||
// Human label for a job: name → first 60 of prompt → first 60 of script → id.
|
||||
// One source for the sidebar row and the Cron page so the two never drift.
|
||||
export function jobTitle(job: CronJob): string {
|
||||
const pick = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
|
||||
const clip = (v: string) => (v.length > 60 ? `${v.slice(0, 60)}…` : v)
|
||||
|
||||
return pick(job.name) || clip(pick(job.prompt)) || clip(pick(job.script)) || job.id || 'Cron job'
|
||||
}
|
||||
|
|
@ -11,8 +11,9 @@ import { Pane, PaneMain } from '@/components/pane-shell'
|
|||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import { getSessionMessages, listAllProfileSessions, type SessionInfo } from '../hermes'
|
||||
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||
import { setCronFocusJobId, setCronJobs } from '../store/cron'
|
||||
import {
|
||||
$panesFlipped,
|
||||
$pinnedSessionIds,
|
||||
|
|
@ -37,10 +38,12 @@ import {
|
|||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
CRON_SECTION_LIMIT,
|
||||
mergeSessionPage,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setCronSessions,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentModel,
|
||||
|
|
@ -71,7 +74,7 @@ import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
|||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
|
||||
import { useCwdActions } from './session/hooks/use-cwd-actions'
|
||||
import { useHermesConfig } from './session/hooks/use-hermes-config'
|
||||
|
|
@ -101,6 +104,21 @@ const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).P
|
|||
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
|
||||
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
|
||||
|
||||
// Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The
|
||||
// Cron sessions are written by a background scheduler tick (the desktop
|
||||
// backend), so no user action signals the UI. Poll the bounded cron list on
|
||||
// this cadence while the app is open + visible so new runs surface promptly
|
||||
// instead of waiting for the next user-triggered refreshSessions().
|
||||
const CRON_POLL_INTERVAL_MS = 30_000
|
||||
|
||||
// Cheap signature compare so the poll only swaps the atom (and re-renders the
|
||||
// sidebar) when the visible cron rows actually changed.
|
||||
function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
|
||||
if (a.length !== b.length) {return false}
|
||||
|
||||
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
|
||||
}
|
||||
|
||||
// Rows a session refresh must preserve even if the aggregator omits them:
|
||||
// in-flight first turns (message_count 0), pinned rows aged off the page, and
|
||||
// the actively-viewed chat (its "working" flag clears a beat before the
|
||||
|
|
@ -224,6 +242,36 @@ export function DesktopController() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Cron-job sessions as their own list (latest N). Independent of the recents
|
||||
// page so the two never compete for slots. Cheap + bounded. Kept (even though
|
||||
// the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run
|
||||
// still resolves into the Pinned section via sessionByAnyId.
|
||||
const refreshCronSessions = useCallback(async () => {
|
||||
try {
|
||||
const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
|
||||
source: 'cron'
|
||||
})
|
||||
|
||||
setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions))
|
||||
} catch {
|
||||
// Non-fatal: the cron section just stays empty/stale.
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
|
||||
// synchronously (agent tool call or the cron UI), so refreshing here right
|
||||
// after an agent turn surfaces a new job immediately; the interval poll keeps
|
||||
// next-run/state fresh as the scheduler advances them.
|
||||
const refreshCronJobs = useCallback(async () => {
|
||||
try {
|
||||
const jobs = await getCronJobs()
|
||||
|
||||
setCronJobs(jobs)
|
||||
} catch {
|
||||
// Non-fatal: the cron section just keeps its last-known jobs.
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
const requestId = refreshSessionsRequestRef.current + 1
|
||||
refreshSessionsRequestRef.current = requestId
|
||||
|
|
@ -231,13 +279,18 @@ export function DesktopController() {
|
|||
|
||||
try {
|
||||
const limit = $sessionsLimit.get()
|
||||
|
||||
// Require at least one message so abandoned/empty "Untitled" drafts (one
|
||||
// was created per TUI/desktop launch before the lazy-create fix) don't
|
||||
// clutter the sidebar.
|
||||
// Unified cross-profile list (served read-only off each profile's
|
||||
// state.db; no per-profile backend is spawned). Single-profile users get
|
||||
// the same rows tagged profile="default".
|
||||
const result = await listAllProfileSessions(limit, 1)
|
||||
// the same rows tagged profile="default". Cron sessions are excluded here
|
||||
// and fetched separately (refreshCronSessions) so the scheduler's
|
||||
// always-newest rows can't consume the recents page budget.
|
||||
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', 'all', {
|
||||
excludeSources: ['cron']
|
||||
})
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
|
||||
|
|
@ -249,7 +302,10 @@ export function DesktopController() {
|
|||
setSessionsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
void refreshCronSessions()
|
||||
void refreshCronJobs()
|
||||
}, [refreshCronSessions, refreshCronJobs])
|
||||
|
||||
const loadMoreSessions = useCallback(() => {
|
||||
bumpSessionsLimit()
|
||||
|
|
@ -262,7 +318,11 @@ export function DesktopController() {
|
|||
const key = normalizeProfileKey(profile)
|
||||
const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key
|
||||
const loaded = $sessions.get().filter(inKey).length
|
||||
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key)
|
||||
|
||||
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
|
||||
excludeSources: ['cron']
|
||||
})
|
||||
|
||||
const keep = sessionsToKeep(key)
|
||||
|
||||
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
|
||||
|
|
@ -560,6 +620,25 @@ export function DesktopController() {
|
|||
}
|
||||
}, [gatewayState, refreshCurrentModel, refreshSessions])
|
||||
|
||||
// Keep the cron jobs section live without a user action: the scheduler ticks
|
||||
// in the background (advancing next-run/state and creating runs), so poll the
|
||||
// job list on an interval (and on tab re-focus) while connected.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {return}
|
||||
|
||||
const tick = () => {
|
||||
if (document.visibilityState === 'visible') {void refreshCronJobs()}
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
|
||||
document.addEventListener('visibilitychange', tick)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId)
|
||||
document.removeEventListener('visibilitychange', tick)
|
||||
}
|
||||
}, [gatewayState, refreshCronJobs])
|
||||
|
||||
useRouteResume({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
|
|
@ -600,9 +679,18 @@ export function DesktopController() {
|
|||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
||||
onLoadMoreSessions={loadMoreSessions}
|
||||
onManageCronJob={jobId => {
|
||||
setCronFocusJobId(jobId)
|
||||
navigate(CRON_ROUTE)
|
||||
}}
|
||||
onNavigate={selectSidebarItem}
|
||||
onNewSessionInWorkspace={startSessionInWorkspace}
|
||||
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
onTriggerCronJob={jobId => {
|
||||
void triggerCronJob(jobId)
|
||||
.then(() => refreshCronJobs())
|
||||
.catch(() => undefined)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
@ -669,7 +757,10 @@ export function DesktopController() {
|
|||
|
||||
{cronOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CronView onClose={closeOverlayToPreviousRoute} />
|
||||
<CronView
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -73,6 +75,31 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// Boxless "+ New …" action that tops an OverlaySidebar list (profiles, cron, …).
|
||||
// The text variant underlines on hover, which also strokes the icon glyph — so
|
||||
// we keep the button itself underline-free and underline only the label span.
|
||||
export function OverlayNewButton({
|
||||
icon = 'add',
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
icon?: string
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className="group mb-1 w-full justify-start gap-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={icon} />
|
||||
<span className="underline-offset-4 group-hover:underline">{label}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
|
||||
return (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -30,7 +29,7 @@ import { cn } from '@/lib/utils'
|
|||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
|
|
@ -145,15 +144,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
) : (
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
<Button
|
||||
className="mb-1 w-full justify-start gap-2"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
<Codicon name="add" />
|
||||
{p.newProfile}
|
||||
</Button>
|
||||
<OverlayNewButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
|
||||
{profiles.map(profile => (
|
||||
<ProfileRow
|
||||
active={selected?.name === profile.name}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ const TOOL_META: Record<string, ToolMeta> = {
|
|||
},
|
||||
browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' },
|
||||
clarify: { done: 'Asked a question', pending: 'Asking a question', icon: 'question', tone: 'agent' },
|
||||
cronjob: { done: 'Cron job', pending: 'Scheduling cron job', icon: 'watch', tone: 'agent' },
|
||||
edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' },
|
||||
execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' },
|
||||
image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' },
|
||||
|
|
@ -899,6 +900,80 @@ function fallbackDetailText(args: unknown, result: unknown): string {
|
|||
return formatToolResultSummary(args) || minimalValueSummary(args)
|
||||
}
|
||||
|
||||
function cronScalar(value: unknown): string {
|
||||
if (typeof value === 'string') return value.trim()
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatCronTime(iso: string): string {
|
||||
const ts = Date.parse(iso)
|
||||
|
||||
if (Number.isNaN(ts)) return iso
|
||||
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function cronjobSubtitle(
|
||||
argsRecord: Record<string, unknown>,
|
||||
resultRecord: Record<string, unknown>
|
||||
): string {
|
||||
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
|
||||
|
||||
if (jobs) {
|
||||
return jobs.length ? `${jobs.length} cron job${jobs.length === 1 ? '' : 's'}` : 'No cron jobs'
|
||||
}
|
||||
|
||||
const message = firstStringField(resultRecord, ['message'])
|
||||
|
||||
if (message) return message
|
||||
|
||||
const action = firstStringField(argsRecord, ['action']) || 'manage'
|
||||
const name = firstStringField(resultRecord, ['name']) || firstStringField(argsRecord, ['name', 'job_id'])
|
||||
const label = `${action[0]?.toUpperCase() ?? ''}${action.slice(1)}`
|
||||
|
||||
return name ? `${label} ${name}` : `Cron ${action}`
|
||||
}
|
||||
|
||||
function cronjobDetail(
|
||||
argsRecord: Record<string, unknown>,
|
||||
resultRecord: Record<string, unknown>
|
||||
): string {
|
||||
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
|
||||
|
||||
if (jobs) {
|
||||
if (!jobs.length) return 'No cron jobs scheduled'
|
||||
|
||||
return jobs
|
||||
.slice(0, 20)
|
||||
.map(job => {
|
||||
const row = isRecord(job) ? job : {}
|
||||
const name = firstStringField(row, ['name', 'id']) || 'job'
|
||||
const sched = firstStringField(row, ['schedule_display', 'schedule'])
|
||||
|
||||
return sched ? `- ${name} · ${sched}` : `- ${name}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const nextRun = cronScalar(resultRecord.next_run_at)
|
||||
const rows: [string, string][] = [
|
||||
['Schedule', cronScalar(resultRecord.schedule)],
|
||||
['Repeat', cronScalar(resultRecord.repeat)],
|
||||
['Delivery', cronScalar(resultRecord.deliver)],
|
||||
['Next run', nextRun ? formatCronTime(nextRun) : '']
|
||||
]
|
||||
const lines = rows.filter(([, value]) => value).map(([key, value]) => `${key}: ${value}`)
|
||||
|
||||
return lines.length ? lines.join('\n') : fallbackDetailText(argsRecord, resultRecord)
|
||||
}
|
||||
|
||||
function toolSubtitle(
|
||||
part: ToolPart,
|
||||
argsRecord: Record<string, unknown>,
|
||||
|
|
@ -992,6 +1067,10 @@ function toolSubtitle(
|
|||
return url ? hostnameOf(url) : 'Fetched webpage'
|
||||
}
|
||||
|
||||
if (toolName === 'cronjob') {
|
||||
return cronjobSubtitle(argsRecord, resultRecord)
|
||||
}
|
||||
|
||||
return (
|
||||
compactPreview(formatToolResultSummary(part.result), 120) ||
|
||||
compactPreview(resultRecord, 120) ||
|
||||
|
|
@ -1092,6 +1171,10 @@ function toolDetailText(
|
|||
.replace(/\bDuration\s+S\s*:/gi, 'Duration:')
|
||||
}
|
||||
|
||||
if (part.toolName === 'cronjob') {
|
||||
return cronjobDetail(argsRecord, resultRecord)
|
||||
}
|
||||
|
||||
return fallbackDetailText(argsRecord, resultRecord)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import type {
|
|||
ProfileSetupCommand,
|
||||
ProfileSoul,
|
||||
ProfilesResponse,
|
||||
SessionInfo,
|
||||
SessionMessagesResponse,
|
||||
SessionSearchResponse,
|
||||
SkillInfo,
|
||||
|
|
@ -149,17 +150,33 @@ export async function listSessions(
|
|||
// primary backend straight off each profile's state.db — no per-profile backend
|
||||
// is spawned. Single-profile users get the same rows as listSessions(), tagged
|
||||
// profile="default".
|
||||
// Source scoping lets callers split the unified list into independent slices:
|
||||
// recents pass `excludeSources: ['cron']`, the cron-jobs section passes
|
||||
// `source: 'cron'`. Without this a burst of (always-newest) cron sessions
|
||||
// consumes the whole recents page and starves real conversations.
|
||||
export interface SessionSourceFilter {
|
||||
source?: string
|
||||
excludeSources?: string[]
|
||||
}
|
||||
|
||||
export async function listAllProfileSessions(
|
||||
limit = 40,
|
||||
minMessages = 0,
|
||||
archived: 'exclude' | 'include' | 'only' = 'exclude',
|
||||
order: 'created' | 'recent' = 'recent',
|
||||
profile: 'all' | (string & {}) = 'all'
|
||||
profile: 'all' | (string & {}) = 'all',
|
||||
filter: SessionSourceFilter = {}
|
||||
): Promise<PaginatedSessions> {
|
||||
const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : ''
|
||||
|
||||
const excludeParam = filter.excludeSources?.length
|
||||
? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}`
|
||||
: ''
|
||||
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path:
|
||||
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
|
||||
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}`
|
||||
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
@ -488,6 +505,14 @@ export function getCronJob(jobId: string): Promise<CronJob> {
|
|||
})
|
||||
}
|
||||
|
||||
export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
|
||||
const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`
|
||||
})
|
||||
|
||||
return runs ?? []
|
||||
}
|
||||
|
||||
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
path: '/api/cron/jobs',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: 'セッションのグループ化を解除',
|
||||
|
|
|
|||
|
|
@ -699,8 +699,6 @@ export interface Translations {
|
|||
cron: {
|
||||
close: string
|
||||
search: string
|
||||
refresh: string
|
||||
refreshing: string
|
||||
loading: string
|
||||
states: Record<string, string>
|
||||
deliveryLabels: Record<string, string>
|
||||
|
|
@ -714,15 +712,18 @@ export interface Translations {
|
|||
monthlyOnDayAt: (dayOfMonth: string, time: string) => string
|
||||
topOfHour: string
|
||||
everyHourAt: (minute: string) => string
|
||||
active: (enabled: number, total: number) => string
|
||||
newCron: string
|
||||
createFirst: string
|
||||
emptyDescNew: string
|
||||
emptyDescSearch: string
|
||||
emptyTitleNew: string
|
||||
emptyTitleSearch: string
|
||||
last: string
|
||||
next: string
|
||||
noRuns: string
|
||||
manage: string
|
||||
showRuns: string
|
||||
hideRuns: string
|
||||
runHistory: string
|
||||
actionsFor: (title: string) => string
|
||||
actionsTitle: string
|
||||
resume: string
|
||||
|
|
@ -809,6 +810,7 @@ export interface Translations {
|
|||
results: string
|
||||
pinned: string
|
||||
sessions: string
|
||||
cronJobs: string
|
||||
groupAriaGrouped: string
|
||||
groupAriaUngrouped: string
|
||||
groupTitleGrouped: string
|
||||
|
|
|
|||
|
|
@ -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: '取消分組',
|
||||
|
|
|
|||
|
|
@ -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: '取消分组',
|
||||
|
|
|
|||
19
apps/desktop/src/store/cron.ts
Normal file
19
apps/desktop/src/store/cron.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import type { CronJob } from '@/types/hermes'
|
||||
|
||||
// Cron *jobs* (not run sessions) power the sidebar "Cron jobs" section. Listing
|
||||
// the job — schedule, state, live next-run countdown — makes the job the
|
||||
// first-class entity; its runs (sessions) resolve under it in the cron detail.
|
||||
export const $cronJobs = atom<CronJob[]>([])
|
||||
export const setCronJobs = (jobs: CronJob[]) => $cronJobs.set(jobs)
|
||||
|
||||
// In-place edit so the cron overlay's mutations (create/edit/delete/pause/…)
|
||||
// land in the same atom the sidebar renders — no stale list until the next poll.
|
||||
export const updateCronJobs = (fn: (jobs: CronJob[]) => CronJob[]) => $cronJobs.set(fn($cronJobs.get()))
|
||||
|
||||
// One-shot focus target: clicking "Manage" on a job sets this, then opens the
|
||||
// cron overlay, which reads it once to select + scroll to that job. Cleared
|
||||
// after consumption so re-opening cron normally doesn't re-focus a stale job.
|
||||
export const $cronFocusJobId = atom<null | string>(null)
|
||||
export const setCronFocusJobId = (id: null | string) => $cronFocusJobId.set(id)
|
||||
|
|
@ -22,6 +22,7 @@ export const SIDEBAR_SESSIONS_PAGE_SIZE = 50
|
|||
|
||||
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
|
||||
const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace'
|
||||
const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen'
|
||||
const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
|
||||
|
||||
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
|
||||
|
|
@ -54,6 +55,10 @@ export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states
|
|||
export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY))
|
||||
export const $sidebarPinsOpen = atom(true)
|
||||
export const $sidebarRecentsOpen = atom(true)
|
||||
// Cron-job sessions live in their own section below recents, collapsed by
|
||||
// default (it only renders at all when cron sessions exist) so the
|
||||
// scheduler's `[IMPORTANT: …]` first-message previews don't spam recents.
|
||||
export const $sidebarCronOpen = atom(storedBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, false))
|
||||
export const $sidebarAgentsGrouped = atom(storedBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false))
|
||||
// When true, the sessions sidebar moves to the right and the file browser +
|
||||
// preview rail move to the left — a mirror of the default layout.
|
||||
|
|
@ -62,6 +67,7 @@ export const $isSidebarResizing = atom(false)
|
|||
export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE)
|
||||
|
||||
$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids]))
|
||||
$sidebarCronOpen.subscribe(open => persistBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, open))
|
||||
$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped))
|
||||
$panesFlipped.subscribe(flipped => persistBoolean(PANES_FLIPPED_STORAGE_KEY, flipped))
|
||||
|
||||
|
|
@ -114,6 +120,10 @@ export function setSidebarRecentsOpen(open: boolean) {
|
|||
$sidebarRecentsOpen.set(open)
|
||||
}
|
||||
|
||||
export function setSidebarCronOpen(open: boolean) {
|
||||
$sidebarCronOpen.set(open)
|
||||
}
|
||||
|
||||
export function setSidebarAgentsGrouped(grouped: boolean) {
|
||||
$sidebarAgentsGrouped.set(grouped)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,15 @@ export const $connection = atom<HermesConnection | null>(null)
|
|||
export const $gatewayState = atom('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
export const $sessionsTotal = atom<number>(0)
|
||||
// Cron-job sessions (source === 'cron') are fetched as their own list so the
|
||||
// scheduler's always-newest sessions never crowd recents out of the page
|
||||
// budget. Powers the collapsed "Cron jobs" sidebar section.
|
||||
export const $cronSessions = atom<SessionInfo[]>([])
|
||||
// Max cron sessions fetched for the sidebar section (single bounded page). When
|
||||
// the fetch returns exactly this many rows we know more exist, so the section
|
||||
// badge renders "N+". Lives here so the controller (fetch) and sidebar (badge)
|
||||
// share one source of truth without a circular import.
|
||||
export const CRON_SECTION_LIMIT = 50
|
||||
// Listable conversation count per profile (children excluded), keyed by profile
|
||||
// name. Lets the sidebar scope its "Load more" footer to the active profile so a
|
||||
// huge default profile doesn't keep "Load more" visible while browsing a small
|
||||
|
|
@ -119,6 +128,7 @@ export const setConnection = (next: Updater<HermesConnection | null>) => updateA
|
|||
export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
|
||||
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
|
||||
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
|
||||
export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
|
||||
export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
|
||||
updateAtom($sessionProfileTotals, next)
|
||||
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue