From 628f9040df438182578381a4fc72e4044509d5bd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 12:30:39 -0500 Subject: [PATCH 01/10] feat(desktop): split cron sessions into their own sidebar section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduler sessions (source=cron) were listed in recents, where their `[IMPORTANT: …]` first-message previews spammed the list — and because cron runs are always newest, a burst of them consumed the whole recents page budget and starved real conversations (sidebar showed 0 sessions). Recents and cron jobs are now two independent lists: - Backend: /api/sessions + /api/profiles/sessions accept source / exclude_sources; session_count gains exclude_sources. Recents query excludes cron; the cron section queries source=cron. - Desktop: separate $cronSessions store + refreshCronSessions fetch, a collapsed (persisted) "Cron jobs" section below Sessions that only renders when cron sessions exist, with its own bounded scroller. --- apps/desktop/src/app/chat/sidebar/index.tsx | 48 +++++++++++++++++++-- apps/desktop/src/app/desktop-controller.tsx | 40 +++++++++++++++-- apps/desktop/src/hermes.ts | 20 ++++++++- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/store/layout.ts | 10 +++++ apps/desktop/src/store/session.ts | 5 +++ hermes_cli/web_server.py | 22 ++++++++++ hermes_state.py | 10 +++++ 12 files changed, 151 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index d46948165dc..26e808745a4 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -44,6 +44,7 @@ import { $panesFlipped, $pinnedSessionIds, $sidebarAgentsGrouped, + $sidebarCronOpen, $sidebarOpen, $sidebarPinsOpen, $sidebarRecentsOpen, @@ -51,6 +52,7 @@ import { reorderPinnedSession, SESSION_SEARCH_FOCUS_EVENT, setSidebarAgentsGrouped, + setSidebarCronOpen, setSidebarPinsOpen, setSidebarRecentsOpen, SIDEBAR_SESSIONS_PAGE_SIZE, @@ -65,6 +67,7 @@ import { normalizeProfileKey } from '@/store/profile' import { + $cronSessions, $selectedStoredSessionId, $sessionProfileTotals, $sessions, @@ -243,8 +246,10 @@ export function ChatSidebar({ const pinnedSessionIds = useStore($pinnedSessionIds) const pinsOpen = useStore($sidebarPinsOpen) const agentsOpen = useStore($sidebarRecentsOpen) + const cronOpen = useStore($sidebarCronOpen) const selectedSessionId = useStore($selectedStoredSessionId) const sessions = useStore($sessions) + const cronSessions = useStore($cronSessions) const sessionsLoading = useStore($sessionsLoading) const sessionsTotal = useStore($sessionsTotal) const sessionProfileTotals = useStore($sessionProfileTotals) @@ -323,7 +328,10 @@ export function ChatSidebar({ const sessionByAnyId = useMemo(() => { const map = new Map() - for (const s of visibleSessions) { + // Cron sessions are listed separately but can still be pinned, so index + // them too — otherwise a pinned cron job can't resolve into the Pinned + // section. Recents take precedence on id collisions (set last). + for (const s of [...cronSessions, ...visibleSessions]) { map.set(s.id, s) if (s._lineage_root_id && !map.has(s._lineage_root_id)) { @@ -332,7 +340,7 @@ export function ChatSidebar({ } return map - }, [visibleSessions]) + }, [visibleSessions, cronSessions]) const pinnedSessions = useMemo(() => { const seen = new Set() @@ -405,6 +413,17 @@ export function ChatSidebar({ return [...out.values()] }, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId]) + // Cron-job sessions are a fully independent list (fetched separately so they + // never consume the recents page budget). Scope them like recents and drop + // any that are pinned (pin wins) to avoid a double-listing. + const visibleCronSessions = useMemo(() => { + const scoped = showAllProfiles + ? cronSessions + : cronSessions.filter(s => normalizeProfileKey(s.profile) === profileScope) + + return scoped.filter(s => !pinnedRealIdSet.has(s.id)).sort((a, b) => sessionTime(b) - sessionTime(a)) + }, [cronSessions, showAllProfiles, profileScope, pinnedRealIdSet]) + const unpinnedAgentSessions = useMemo( () => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)), [sortedSessions, pinnedRealIdSet] @@ -482,7 +501,10 @@ export function ChatSidebar({ ]) const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 - const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 + + const showSessionSections = + showSessionSkeletons || sortedSessions.length > 0 || visibleCronSessions.length > 0 + // Pagination is scope-aware. In "All profiles" mode it tracks the global // unified set. When scoped to one profile it must compare that profile's own // loaded rows against that profile's total — otherwise a huge default profile @@ -759,6 +781,26 @@ export function ChatSidebar({ /> )} + {sidebarOpen && showSessionSections && !trimmedQuery && visibleCronSessions.length > 0 && ( + setSidebarCronOpen(!cronOpen)} + onTogglePin={pinSession} + open={cronOpen} + pinned={false} + rootClassName="shrink-0 p-0 pb-1" + sessions={visibleCronSessions} + workingSessionIdSet={workingSessionIdSet} + /> + )} + {sidebarOpen && !showSessionSections &&
} {sidebarOpen && ( diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index e6ef5cc64f3..48f56b8077e 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -41,6 +41,7 @@ import { sessionPinId, setAwaitingResponse, setBusy, + setCronSessions, setCurrentBranch, setCurrentCwd, setCurrentModel, @@ -101,6 +102,11 @@ const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).P const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView })) const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView })) +// Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The +// section shows the most-recent jobs, not the full history (that lives in +// search), so this stays small and is fetched as a single bounded page. +const CRON_SECTION_LIMIT = 50 + // Rows a session refresh must preserve even if the aggregator omits them: // in-flight first turns (message_count 0), pinned rows aged off the page, and // the actively-viewed chat (its "working" flag clears a beat before the @@ -224,6 +230,21 @@ export function DesktopController() { } }, []) + // Cron-job sessions as their own list (latest N). Independent of the recents + // page so the two never compete for slots. Cheap + bounded; refreshed + // alongside recents. + const refreshCronSessions = useCallback(async () => { + try { + const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { + source: 'cron' + }) + + setCronSessions(sessions) + } catch { + // Non-fatal: the cron section just stays empty/stale. + } + }, []) + const refreshSessions = useCallback(async () => { const requestId = refreshSessionsRequestRef.current + 1 refreshSessionsRequestRef.current = requestId @@ -231,13 +252,18 @@ export function DesktopController() { try { const limit = $sessionsLimit.get() + // Require at least one message so abandoned/empty "Untitled" drafts (one // was created per TUI/desktop launch before the lazy-create fix) don't // clutter the sidebar. // Unified cross-profile list (served read-only off each profile's // state.db; no per-profile backend is spawned). Single-profile users get - // the same rows tagged profile="default". - const result = await listAllProfileSessions(limit, 1) + // the same rows tagged profile="default". Cron sessions are excluded here + // and fetched separately (refreshCronSessions) so the scheduler's + // always-newest rows can't consume the recents page budget. + const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', 'all', { + excludeSources: ['cron'] + }) if (refreshSessionsRequestRef.current === requestId) { setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep())) @@ -249,7 +275,9 @@ export function DesktopController() { setSessionsLoading(false) } } - }, []) + + void refreshCronSessions() + }, [refreshCronSessions]) const loadMoreSessions = useCallback(() => { bumpSessionsLimit() @@ -262,7 +290,11 @@ export function DesktopController() { const key = normalizeProfileKey(profile) const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key const loaded = $sessions.get().filter(inKey).length - const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key) + + const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, { + excludeSources: ['cron'] + }) + const keep = sessionsToKeep(key) setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)]) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index c4621ba8d83..20a4c805113 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -149,17 +149,33 @@ export async function listSessions( // primary backend straight off each profile's state.db — no per-profile backend // is spawned. Single-profile users get the same rows as listSessions(), tagged // profile="default". +// Source scoping lets callers split the unified list into independent slices: +// recents pass `excludeSources: ['cron']`, the cron-jobs section passes +// `source: 'cron'`. Without this a burst of (always-newest) cron sessions +// consumes the whole recents page and starves real conversations. +export interface SessionSourceFilter { + source?: string + excludeSources?: string[] +} + export async function listAllProfileSessions( limit = 40, minMessages = 0, archived: 'exclude' | 'include' | 'only' = 'exclude', order: 'created' | 'recent' = 'recent', - profile: 'all' | (string & {}) = 'all' + profile: 'all' | (string & {}) = 'all', + filter: SessionSourceFilter = {} ): Promise { + const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : '' + + const excludeParam = filter.excludeSources?.length + ? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}` + : '' + const result = await window.hermesDesktop.api({ path: `/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + - `&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}` + `&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}` }) return { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 1f604f3906b..29650b2d5ca 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1044,6 +1044,7 @@ export const en: Translations = { results: 'Results', pinned: 'Pinned', sessions: 'Sessions', + cronJobs: 'Cron jobs', groupAriaGrouped: 'Show sessions as a single list', groupAriaUngrouped: 'Group sessions by workspace', groupTitleGrouped: 'Ungroup sessions', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 3565add8fe3..4b6b120a409 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1147,6 +1147,7 @@ export const ja = defineLocale({ results: '結果', pinned: 'ピン留め', sessions: 'セッション', + cronJobs: 'Cronジョブ', groupAriaGrouped: 'セッションを単一リストとして表示', groupAriaUngrouped: 'ワークスペースごとにセッションをグループ化', groupTitleGrouped: 'セッションのグループ化を解除', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index cc2281c366e..c5495fe4a93 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -801,6 +801,7 @@ export interface Translations { results: string pinned: string sessions: string + cronJobs: string groupAriaGrouped: string groupAriaUngrouped: string groupTitleGrouped: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 09ce699ea09..4051996d24b 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1113,6 +1113,7 @@ export const zhHant = defineLocale({ results: '結果', pinned: '已釘選', sessions: '工作階段', + cronJobs: '排程任務', groupAriaGrouped: '以單一清單顯示工作階段', groupAriaUngrouped: '依工作區分組工作階段', groupTitleGrouped: '取消分組', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 3472352b908..262dc9afa3a 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1191,6 +1191,7 @@ export const zh: Translations = { results: '结果', pinned: '已置顶', sessions: '会话', + cronJobs: '定时任务', groupAriaGrouped: '以单一列表显示会话', groupAriaUngrouped: '按工作区分组会话', groupTitleGrouped: '取消分组', diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index f29605f7715..c01d8b58bd3 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -22,6 +22,7 @@ export const SIDEBAR_SESSIONS_PAGE_SIZE = 50 const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions' const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace' +const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen' const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped' export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar' @@ -54,6 +55,10 @@ export const $sidebarWidth: ReadableAtom = computed($paneStates, states export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY)) export const $sidebarPinsOpen = atom(true) export const $sidebarRecentsOpen = atom(true) +// Cron-job sessions live in their own section below recents, collapsed by +// default (it only renders at all when cron sessions exist) so the +// scheduler's `[IMPORTANT: …]` first-message previews don't spam recents. +export const $sidebarCronOpen = atom(storedBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, false)) export const $sidebarAgentsGrouped = atom(storedBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false)) // When true, the sessions sidebar moves to the right and the file browser + // preview rail move to the left — a mirror of the default layout. @@ -62,6 +67,7 @@ export const $isSidebarResizing = atom(false) export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE) $pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids])) +$sidebarCronOpen.subscribe(open => persistBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, open)) $sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped)) $panesFlipped.subscribe(flipped => persistBoolean(PANES_FLIPPED_STORAGE_KEY, flipped)) @@ -114,6 +120,10 @@ export function setSidebarRecentsOpen(open: boolean) { $sidebarRecentsOpen.set(open) } +export function setSidebarCronOpen(open: boolean) { + $sidebarCronOpen.set(open) +} + export function setSidebarAgentsGrouped(grouped: boolean) { $sidebarAgentsGrouped.set(grouped) } diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index d60b22e6bf7..60f669a697c 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -76,6 +76,10 @@ export const $connection = atom(null) export const $gatewayState = atom('idle') export const $sessions = atom([]) export const $sessionsTotal = atom(0) +// Cron-job sessions (source === 'cron') are fetched as their own list so the +// scheduler's always-newest sessions never crowd recents out of the page +// budget. Powers the collapsed "Cron jobs" sidebar section. +export const $cronSessions = atom([]) // 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 +123,7 @@ export const setConnection = (next: Updater) => updateA export const setGatewayState = (next: Updater) => updateAtom($gatewayState, next) export const setSessions = (next: Updater) => updateAtom($sessions, next) export const setSessionsTotal = (next: Updater) => updateAtom($sessionsTotal, next) +export const setCronSessions = (next: Updater) => updateAtom($cronSessions, next) export const setSessionProfileTotals = (next: Updater>) => updateAtom($sessionProfileTotals, next) export const setSessionsLoading = (next: Updater) => updateAtom($sessionsLoading, next) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 95cfd34fc14..8afb820988d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1574,6 +1574,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 +1606,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 +1622,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 +1653,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 +1690,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 +1718,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 +1728,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, diff --git a/hermes_state.py b/hermes_state.py index 5a6aa8e8a62..90247a02a0e 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3198,6 +3198,7 @@ class SessionDB: include_archived: bool = False, archived_only: bool = False, exclude_children: bool = False, + exclude_sources: List[str] = None, ) -> int: """Count sessions, optionally filtered by source. @@ -3207,6 +3208,11 @@ class SessionDB: is paired with a ``list_sessions_rich`` page (e.g. sidebar "load more" totals) so the total matches the number of listable rows — otherwise the raw row count is inflated by children and "load more" never settles. + + Pass ``exclude_sources`` to drop whole source classes from the count + (e.g. ``["cron"]`` so the recents "load more" total matches a + cron-excluded ``list_sessions_rich`` page and doesn't keep "load more" + stuck on for buried scheduler sessions). """ where_clauses = [] params = [] @@ -3225,6 +3231,10 @@ class SessionDB: if source: where_clauses.append("s.source = ?") params.append(source) + if exclude_sources: + placeholders = ",".join("?" for _ in exclude_sources) + where_clauses.append(f"s.source NOT IN ({placeholders})") + params.extend(exclude_sources) if min_message_count > 0: where_clauses.append("s.message_count >= ?") params.append(min_message_count) From 3e2d758816b72a0151cfaad8933bb073a74871f7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 12:42:32 -0500 Subject: [PATCH 02/10] feat(desktop): fire cron jobs from the dashboard backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cron scheduler tick loop only ran inside `hermes gateway run`, but the desktop app spawns a `hermes dashboard` backend with no gateway — so any cron a user created in the app was saved and never fired (silently). Run a minimal scheduler ticker inside the dashboard lifespan, gated on a new HERMES_DESKTOP=1 marker the electron shell injects, so server `hermes dashboard` is unaffected. Cross-process safe via the existing cron/.tick.lock, so it never double-fires alongside a real gateway. --- apps/desktop/electron/main.cjs | 6 ++++ hermes_cli/web_server.py | 46 ++++++++++++++++++++++++++++- tests/hermes_cli/test_web_server.py | 35 ++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 3ea31b2720f..09e5dfac6b6 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -4274,6 +4274,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, @@ -4415,6 +4418,9 @@ async function startHermes() { HERMES_HOME, ...backend.env, HERMES_DASHBOARD_SESSION_TOKEN: token, + // Marks this dashboard backend as desktop-spawned so it runs the cron + // scheduler tick loop (the gateway isn't running under the app). + HERMES_DESKTOP: '1', HERMES_WEB_DIST: webDist }, shell: backend.shell, diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8afb820988d..6bf554a98f0 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -102,11 +102,55 @@ _log = logging.getLogger(__name__) # when the same module is used across TestClient instances or uvicorn reloads. # --------------------------------------------------------------------------- +def _start_desktop_cron_ticker(stop_event: "threading.Event", interval: int = 60) -> None: + """Tick the cron scheduler from inside the desktop dashboard backend. + + The scheduler tick loop normally lives in ``hermes gateway run`` — but the + desktop app spawns a ``hermes dashboard`` backend, not a gateway, so a cron + a user creates in the app would never fire. We run a minimal ticker here + (no live adapters; delivery falls back to the per-platform send path). + + Cross-process safe: ``cron.scheduler.tick`` takes the ``cron/.tick.lock`` + file lock, so this never double-fires alongside a real gateway on the same + HERMES_HOME — whichever process grabs the lock first wins the tick. + """ + from cron.scheduler import tick as cron_tick + + _log.info("Desktop cron ticker started (interval=%ds)", interval) + # Tick once up front (catches jobs due at launch), then on the interval. + while not stop_event.is_set(): + try: + cron_tick(verbose=False, sync=False) + except Exception as e: + _log.debug("Desktop cron tick error: %s", e) + stop_event.wait(interval) + + @asynccontextmanager async def _lifespan(app: "FastAPI"): app.state.event_channels = {} # dict[str, set] app.state.event_lock = asyncio.Lock() - yield + + # Desktop-spawned backends (HERMES_DESKTOP=1) fire cron jobs themselves, + # since the app has no gateway running the scheduler. Server `hermes + # dashboard` is unaffected — it relies on its own gateway. + cron_stop: "threading.Event | None" = None + cron_thread: "threading.Thread | None" = None + if os.getenv("HERMES_DESKTOP") == "1": + cron_stop = threading.Event() + cron_thread = threading.Thread( + target=_start_desktop_cron_ticker, + args=(cron_stop,), + daemon=True, + name="desktop-cron-ticker", + ) + cron_thread.start() + + try: + yield + finally: + if cron_stop is not None: + cron_stop.set() def _get_event_state(app: "FastAPI"): diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 60d2b7b5c18..bb3085eff22 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4264,3 +4264,38 @@ class TestValidateProviderCredential: def test_empty_value_rejected(self): data = self._post("OPENAI_API_KEY", " ").json() assert data["ok"] is False + + +class TestDesktopCronTicker: + """The dashboard backend fires cron jobs itself only when desktop-spawned.""" + + def _client(self): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + from hermes_cli.web_server import app + + return TestClient(app) + + def test_ticker_runs_when_desktop(self, monkeypatch, _isolate_hermes_home): + import threading + import cron.scheduler as sched + + called = threading.Event() + monkeypatch.setattr(sched, "tick", lambda *a, **k: called.set()) + monkeypatch.setenv("HERMES_DESKTOP", "1") + + with self._client(): + assert called.wait(3.0), "expected cron tick under HERMES_DESKTOP=1" + + def test_ticker_skipped_without_desktop(self, monkeypatch, _isolate_hermes_home): + import threading + import cron.scheduler as sched + + called = threading.Event() + monkeypatch.setattr(sched, "tick", lambda *a, **k: called.set()) + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + + with self._client(): + assert not called.wait(0.5), "ticker must not run outside the desktop app" From ad0f6db151bf00f3d5b7303a45a961f735fbdb3a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 12:51:12 -0500 Subject: [PATCH 03/10] feat(cron): title cron sessions from the job, not the [IMPORTANT] hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cron session's first message is the injected "[IMPORTANT: you are running as a scheduled cron job …]" delivery hint, so with no explicit title the sidebar and history rows fell back to that hint as their label. Set the session title from the job (name → short prompt → id) with a run-time suffix for uniqueness against the sessions.title index. Done after the run so the agent's own INSERT keeps model/system_prompt — this only updates the title. --- cron/scheduler.py | 12 ++++++++++++ tests/cron/test_scheduler.py | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/cron/scheduler.py b/cron/scheduler.py index 38b7b95ab7f..f5c71ceed4f 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -1973,6 +1973,18 @@ def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]: for _var_name in _cron_delivery_vars: _VAR_MAP[_var_name].set("") if _session_db: + # Title the cron session from the job (name → short prompt → id) so + # sidebars/history show a meaningful label instead of the injected + # "[IMPORTANT: …]" hint that is the session's first message. Set here + # (not at create time) so the agent's own INSERT keeps model / + # system_prompt; this only UPDATEs the title column. The run-time + # suffix keeps it unique against the sessions.title index across runs. + try: + _title_base = " ".join(job_name.split())[:60].strip() or f"cron {job_id}" + _cron_title = f"{_title_base} · {_hermes_now().strftime('%b %d %H:%M')}" + _session_db.set_session_title(_cron_session_id, _cron_title) + except (Exception, KeyboardInterrupt) as e: + logger.debug("Job '%s': failed to set cron session title: %s", job_id, e) try: _session_db.end_session(_cron_session_id, "cron_complete") except (Exception, KeyboardInterrupt) as e: diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 432beb764f9..8056d3c49e2 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -912,6 +912,43 @@ class TestRunJobSessionPersistence: fake_db.close.assert_called_once() mock_agent.close.assert_called_once() + def test_run_job_titles_cron_session_from_job_not_important_hint(self, tmp_path): + # The cron session's first message is the injected "[IMPORTANT: …]" + # hint, which used to surface as the sidebar/history row label. run_job + # must title the session from the job (name → short prompt → id). + job = { + "id": "test-job", + "name": "Morning digest", + "prompt": "summarize my inbox", + } + fake_db = MagicMock() + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + }, + ), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + + run_job(job) + + fake_db.set_session_title.assert_called_once() + sid, title = fake_db.set_session_title.call_args[0] + assert sid.startswith("cron_test-job_") + assert "IMPORTANT" not in title + assert title.startswith("Morning digest") + def test_run_job_closes_agent_on_failure_to_prevent_fd_leak(self, tmp_path): # Regression: if ``run_conversation`` raises, the ephemeral cron # agent was previously leaked — over days of ticks this accumulated From 471a5fc5c93e938729c17108e9faf38690b6f58b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 14:04:11 -0500 Subject: [PATCH 04/10] feat(desktop): make cron jobs the first-class sidebar entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the cron surface around jobs (not run sessions), following power-user patterns (GitHub Actions / Airflow / Dagu): master → detail → output. Sidebar "Cron jobs" section: - jobs with a state pip + live next-run countdown - click toggles an inline run-history peek; a run opens its chat (active run highlighted) - hover: trigger-now + manage (open the Cron page) - capped at 50 with a "50+" badge Cron page: de-nested from a collapse-in-row accordion to master/detail — job list + the selected job's schedule, actions, and run history. Backend: GET /api/cron/jobs/{id}/runs lists a job's run sessions. Share STATE_DOT/jobState across both surfaces; drop dead code/keys. --- .../app/chat/sidebar/cron-jobs-section.tsx | 337 ++++++++++++++++++ apps/desktop/src/app/chat/sidebar/index.tsx | 43 +-- .../src/app/cron/cron-job-actions-menu.tsx | 114 ------ apps/desktop/src/app/cron/index.tsx | 299 +++++++++++++--- apps/desktop/src/app/cron/job-state.ts | 20 ++ apps/desktop/src/app/desktop-controller.tsx | 79 +++- .../assistant-ui/tool-fallback-model.ts | 83 +++++ apps/desktop/src/hermes.ts | 9 + apps/desktop/src/i18n/en.ts | 5 + apps/desktop/src/i18n/ja.ts | 5 + apps/desktop/src/i18n/types.ts | 5 + apps/desktop/src/i18n/zh-hant.ts | 5 + apps/desktop/src/i18n/zh.ts | 5 + apps/desktop/src/store/cron.ts | 15 + apps/desktop/src/store/session.ts | 5 + hermes_cli/web_server.py | 47 +++ 16 files changed, 866 insertions(+), 210 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx delete mode 100644 apps/desktop/src/app/cron/cron-job-actions-menu.tsx create mode 100644 apps/desktop/src/app/cron/job-state.ts create mode 100644 apps/desktop/src/store/cron.ts diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx new file mode 100644 index 00000000000..a168b79eed2 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -0,0 +1,337 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useMemo, useState } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar' +import { Tip } from '@/components/ui/tooltip' +import { getCronJobRuns, type SessionInfo } from '@/hermes' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' +import { $selectedStoredSessionId } from '@/store/session' +import type { CronJob } from '@/types/hermes' + +import { jobState, STATE_DOT } from '../../cron/job-state' +import { SidebarPanelLabel } from '../../shell/sidebar-label' + +const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused']) + +// Recent runs shown in the inline quick-peek — enough to glance at history +// without turning the sidebar into the full Cron page. +const PEEK_RUN_LIMIT = 5 + +// Runs are written by the background scheduler tick (no UI signal), so poll the +// open peek so a freshly-fired run shows up within a few seconds. +const PEEK_POLL_INTERVAL_MS = 8000 + +function jobLabel(job: CronJob): string { + const name = (job.name ?? '').trim() + + if (name) {return name} + + const prompt = (job.prompt ?? '').trim() + + if (prompt) {return prompt.length > 60 ? `${prompt.slice(0, 60)}…` : prompt} + + return job.id +} + +const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' }) + +// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the +// coarsest sensible unit so a daily job reads "in 14 hr", not "in 840 min". +function relativeTime(targetMs: number, nowMs: number): string { + const diff = targetMs - nowMs + const abs = Math.abs(diff) + const sign = diff < 0 ? -1 : 1 + + if (abs < 60_000) {return relativeFmt.format(sign * Math.round(abs / 1000), 'second')} + + if (abs < 3_600_000) {return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')} + + if (abs < 86_400_000) {return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')} + + return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day') +} + +function nextRunMs(job: CronJob): null | number { + if (!job.next_run_at) {return null} + + const ms = Date.parse(job.next_run_at) + + return Number.isNaN(ms) ? null : ms +} + +// Runs all belong to the same job, so the run name just repeats the job name — +// the timestamp is what tells them apart. Compact (no year, no seconds) for the +// narrow sidebar. +function formatRunTime(seconds?: null | number): string { + if (!seconds) {return '—'} + + const date = new Date(seconds * 1000) + + return Number.isNaN(date.valueOf()) + ? '—' + : date.toLocaleString(undefined, { day: 'numeric', hour: 'numeric', minute: '2-digit', month: 'short' }) +} + +interface SidebarCronJobsSectionProps { + jobs: CronJob[] + label: string + max?: number + // Open a run session's chat (1 click to output). + onOpenRun: (sessionId: string) => void + // Open the full Cron page focused on this job (manage / full history). + onManageJob: (jobId: string) => void + // Fire the job now. + onTriggerJob: (jobId: string) => void + onToggle: () => void + open: boolean +} + +export function SidebarCronJobsSection({ + jobs, + label, + max = 50, + onManageJob, + onOpenRun, + onTriggerJob, + onToggle, + open +}: SidebarCronJobsSectionProps) { + const [nowMs, setNowMs] = useState(() => Date.now()) + // Single-open inline peek so the section stays scannable. + const [peekJobId, setPeekJobId] = useState(null) + + // One clock for the whole section (rows are pure) so the countdowns tick + // without re-rendering the rest of the sidebar. Only runs while expanded. + useEffect(() => { + if (!open) {return} + + const id = window.setInterval(() => setNowMs(Date.now()), 1000) + + return () => window.clearInterval(id) + }, [open]) + + // Upcoming first (soonest next run), jobs with no next run sink to the bottom, + // then alphabetical for stability. + const sorted = useMemo(() => { + return [...jobs].sort((a, b) => { + const an = nextRunMs(a) + const bn = nextRunMs(b) + + if (an !== null && bn !== null && an !== bn) {return an - bn} + + if (an === null && bn !== null) {return 1} + + if (an !== null && bn === null) {return -1} + + return jobLabel(a).localeCompare(jobLabel(b)) + }) + }, [jobs]) + + const shown = sorted.slice(0, max) + // When capped, signal "50+" rather than implying the list is complete. + const countLabel = jobs.length > max ? `${max}+` : String(jobs.length) + + return ( + +
+ +
+ {open && ( + + {shown.map(job => ( + onManageJob(job.id)} + onOpenRun={onOpenRun} + onTogglePeek={() => setPeekJobId(prev => (prev === job.id ? null : job.id))} + onTrigger={() => onTriggerJob(job.id)} + /> + ))} + + )} +
+ ) +} + +function CronJobSidebarRow({ + expanded, + job, + nowMs, + onManage, + onOpenRun, + onTogglePeek, + onTrigger +}: { + expanded: boolean + job: CronJob + nowMs: number + onManage: () => void + onOpenRun: (sessionId: string) => void + onTogglePeek: () => void + onTrigger: () => void +}) { + const { t } = useI18n() + const c = t.cron + const state = jobState(job) + const next = nextRunMs(job) + const label = jobLabel(job) + + const meta = INACTIVE_STATES.has(state) + ? (c.states[state] ?? state) + : next !== null + ? relativeTime(next, nowMs) + : '—' + + return ( +
+
+ {/* Lead with the dot in the same w-3.5 cell + pl-2 the session rows use + so the cron dots line up with the sessions above; the caret sits next + to the label (matching the other sidebar disclosures) and the whole + label area toggles the run peek. */} + + {/* Trailing cluster: countdown by default, quick actions on hover. */} +
+ + {meta} + +
+ + + + + + +
+
+
+ {expanded && } +
+ ) +} + +function CronJobSidebarRuns({ + jobId, + onOpenRun +}: { + jobId: string + onOpenRun: (sessionId: string) => void +}) { + const { t } = useI18n() + const c = t.cron + const selectedSessionId = useStore($selectedStoredSessionId) + const [runs, setRuns] = useState(null) + + useEffect(() => { + let cancelled = false + + const load = () => + getCronJobRuns(jobId, PEEK_RUN_LIMIT) + .then(result => { + if (!cancelled) {setRuns(result)} + }) + .catch(() => { + if (!cancelled) {setRuns(prev => prev ?? [])} + }) + + void load() + + const intervalId = window.setInterval(() => { + if (document.visibilityState === 'visible') {void load()} + }, PEEK_POLL_INTERVAL_MS) + + return () => { + cancelled = true + window.clearInterval(intervalId) + } + }, [jobId]) + + return ( +
+ {runs === null ? ( +
+ +
+ ) : runs.length === 0 ? ( +
{c.noRuns}
+ ) : ( + <> + {runs.map(run => ( + + ))} + + )} +
+ ) +} diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 26e808745a4..ef1832837f3 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -40,6 +40,7 @@ import { useI18n } from '@/i18n' import { profileColor } from '@/lib/profile-color' import { sessionMatchesSearch } from '@/lib/session-search' import { cn } from '@/lib/utils' +import { $cronJobs } from '@/store/cron' import { $panesFlipped, $pinnedSessionIds, @@ -81,6 +82,7 @@ import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '.. import { SidebarPanelLabel } from '../../shell/sidebar-label' import type { SidebarNavItem } from '../../types' +import { SidebarCronJobsSection } from './cron-jobs-section' import { ProfileRail } from './profile-switcher' import { SidebarSessionRow } from './session-row' import { VirtualSessionList } from './virtual-session-list' @@ -226,6 +228,8 @@ interface ChatSidebarProps extends React.ComponentProps { onDeleteSession: (sessionId: string) => void onArchiveSession: (sessionId: string) => void onNewSessionInWorkspace: (path: null | string) => void + onManageCronJob: (jobId: string) => void + onTriggerCronJob: (jobId: string) => void } export function ChatSidebar({ @@ -236,7 +240,9 @@ export function ChatSidebar({ onResumeSession, onDeleteSession, onArchiveSession, - onNewSessionInWorkspace + onNewSessionInWorkspace, + onManageCronJob, + onTriggerCronJob }: ChatSidebarProps) { const { t } = useI18n() const s = t.sidebar @@ -250,6 +256,7 @@ export function ChatSidebar({ const selectedSessionId = useStore($selectedStoredSessionId) const sessions = useStore($sessions) const cronSessions = useStore($cronSessions) + const cronJobs = useStore($cronJobs) const sessionsLoading = useStore($sessionsLoading) const sessionsTotal = useStore($sessionsTotal) const sessionProfileTotals = useStore($sessionProfileTotals) @@ -413,17 +420,6 @@ export function ChatSidebar({ return [...out.values()] }, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId]) - // Cron-job sessions are a fully independent list (fetched separately so they - // never consume the recents page budget). Scope them like recents and drop - // any that are pinned (pin wins) to avoid a double-listing. - const visibleCronSessions = useMemo(() => { - const scoped = showAllProfiles - ? cronSessions - : cronSessions.filter(s => normalizeProfileKey(s.profile) === profileScope) - - return scoped.filter(s => !pinnedRealIdSet.has(s.id)).sort((a, b) => sessionTime(b) - sessionTime(a)) - }, [cronSessions, showAllProfiles, profileScope, pinnedRealIdSet]) - const unpinnedAgentSessions = useMemo( () => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)), [sortedSessions, pinnedRealIdSet] @@ -502,8 +498,7 @@ export function ChatSidebar({ const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 - const showSessionSections = - showSessionSkeletons || sortedSessions.length > 0 || visibleCronSessions.length > 0 + const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 // Pagination is scope-aware. In "All profiles" mode it tracks the global // unified set. When scoped to one profile it must compare that profile's own @@ -781,23 +776,15 @@ export function ChatSidebar({ /> )} - {sidebarOpen && showSessionSections && !trimmedQuery && visibleCronSessions.length > 0 && ( - 0 && ( + setSidebarCronOpen(!cronOpen)} - onTogglePin={pinSession} + onTriggerJob={onTriggerCronJob} open={cronOpen} - pinned={false} - rootClassName="shrink-0 p-0 pb-1" - sessions={visibleCronSessions} - workingSessionIdSet={workingSessionIdSet} /> )} diff --git a/apps/desktop/src/app/cron/cron-job-actions-menu.tsx b/apps/desktop/src/app/cron/cron-job-actions-menu.tsx deleted file mode 100644 index 2993a1c7411..00000000000 --- a/apps/desktop/src/app/cron/cron-job-actions-menu.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import type * as React from 'react' - -import { Button } from '@/components/ui/button' -import { Codicon } from '@/components/ui/codicon' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { useI18n } from '@/i18n' -import { triggerHaptic } from '@/lib/haptics' - -interface CronJobActions { - busy?: boolean - isPaused: boolean - title: string - onDelete: () => void - onEdit: () => void - onPauseResume: () => void - onTrigger: () => void -} - -interface CronJobActionsMenuProps - extends CronJobActions, Pick, 'align' | 'sideOffset'> { - children: React.ReactNode -} - -export function CronJobActionsMenu({ - align = 'end', - busy = false, - children, - isPaused, - onDelete, - onEdit, - onPauseResume, - onTrigger, - sideOffset = 6, - title -}: CronJobActionsMenuProps) { - const { t } = useI18n() - const c = t.cron - - return ( - - {children} - - { - triggerHaptic('selection') - onPauseResume() - }} - > - - {isPaused ? c.resumeTitle : c.pauseTitle} - - - { - triggerHaptic('selection') - onTrigger() - }} - > - - {c.triggerNow} - - - { - triggerHaptic('selection') - onEdit() - }} - > - - {c.edit} - - - { - triggerHaptic('warning') - onDelete() - }} - variant="destructive" - > - - {t.common.delete} - - - - ) -} - -interface CronJobActionsTriggerProps extends Omit, 'size' | 'variant'> { - title: string -} - -export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) { - const { t } = useI18n() - - return ( - - ) -} diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index dcf852e6aa5..c7da8b91897 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -1,5 +1,6 @@ +import { useStore } from '@nanostores/react' import type * as React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' @@ -19,15 +20,18 @@ import { createCronJob, type CronJob, deleteCronJob, + getCronJobRuns, getCronJobs, pauseCronJob, resumeCronJob, + type SessionInfo, triggerCronJob, updateCronJob } from '@/hermes' import { type Translations, useI18n } from '@/i18n' import { AlertTriangle, Clock } from '@/lib/icons' import { cn } from '@/lib/utils' +import { $cronFocusJobId, setCronFocusJobId } from '@/store/cron' import { notify, notifyError } from '@/store/notifications' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' @@ -35,7 +39,7 @@ import { OverlayView } from '../overlays/overlay-view' import { PageSearchShell } from '../page-search-shell' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' -import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu' +import { jobState, STATE_DOT } from './job-state' const DEFAULT_DELIVER = 'local' @@ -110,10 +114,6 @@ function jobScheduleExpr(job: CronJob): string { return asText(job.schedule?.expr) || asText(job.schedule_display) || '' } -function jobState(job: CronJob): string { - return asText(job.state) || (job.enabled === false ? 'disabled' : 'scheduled') -} - function jobDeliver(job: CronJob): string { return asText(job.deliver) || DEFAULT_DELIVER } @@ -261,16 +261,28 @@ function matchesQuery(job: CronJob, q: string): boolean { interface CronViewProps extends React.ComponentProps<'section'> { onClose: () => void + onOpenSession?: (sessionId: string) => void setStatusbarItemGroup?: SetStatusbarItemGroup } -export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) { +export function CronView({ + onClose, + onOpenSession, + setStatusbarItemGroup: _setStatusbarItemGroup, + ...props +}: CronViewProps) { const { t } = useI18n() const c = t.cron const [jobs, setJobs] = useState(null) const [query, setQuery] = useState('') const [refreshing, setRefreshing] = useState(false) const [busyJobId, setBusyJobId] = useState(null) + // Master/detail: the job whose schedule + run history fill the right pane. + const [selectedJobId, setSelectedJobId] = useState(null) + // Set when a job is opened from the sidebar so we scroll it into view once the + // row exists. Cleared after the scroll fires. + const pendingScrollRef = useRef(null) + const focusJobId = useStore($cronFocusJobId) const [editor, setEditor] = useState({ mode: 'closed' }) const [pendingDelete, setPendingDelete] = useState(null) @@ -295,6 +307,22 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou void refresh() }, [refresh]) + // Sidebar → "open this job": resolve the focus id (or name) to a job, select + // it, queue a scroll, then clear the one-shot focus so re-opening cron + // normally doesn't re-trigger it. + useEffect(() => { + if (!focusJobId || !jobs) {return} + + const match = jobs.find(job => job.id === focusJobId || jobName(job) === focusJobId) + + if (match) { + setSelectedJobId(match.id) + pendingScrollRef.current = match.id + } + + setCronFocusJobId(null) + }, [focusJobId, jobs]) + const visibleJobs = useMemo(() => { if (!jobs) { return [] @@ -303,6 +331,25 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou return jobs.filter(job => matchesQuery(job, query.trim())).sort((a, b) => jobTitle(a).localeCompare(jobTitle(b))) }, [jobs, query]) + // Detail always reflects a concrete job: the explicitly selected one, else the + // first visible row, so the right pane is never empty while jobs exist. + const selectedJob = useMemo( + () => visibleJobs.find(job => job.id === selectedJobId) ?? visibleJobs[0] ?? null, + [visibleJobs, selectedJobId] + ) + + // Scroll a sidebar-opened job into view once its list row is mounted. + useEffect(() => { + const target = pendingScrollRef.current + + if (!target || selectedJob?.id !== target) {return} + + pendingScrollRef.current = null + requestAnimationFrame(() => { + document.querySelector(`[data-cron-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' }) + }) + }, [selectedJob]) + const enabledCount = jobs?.filter(job => job.enabled).length ?? 0 const totalCount = jobs?.length ?? 0 @@ -420,12 +467,11 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch} /> ) : ( -
- {/* Inline header replaces the old top-bar "New cron" button. We - still need a single, always-visible affordance to add a job - when the list is non-empty (rows themselves only expose - edit/pause/trigger/delete). */} -
+ // Master/detail: job list on the left, the selected job's schedule, + // actions, and run history on the right. Replaces the old accordion + // (collapse-in-row inside a modal) — fewer clicks, no nested toggles. +
+
{c.active(enabledCount, totalCount)} @@ -434,19 +480,32 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou {c.newCron}
-
- {visibleJobs.map(job => ( - setPendingDelete(job)} - onEdit={() => setEditor({ mode: 'edit', job })} - onPauseResume={() => void handlePauseResume(job)} - onTrigger={() => void handleTrigger(job)} - /> - ))} +
+
+ {visibleJobs.map(job => ( + setSelectedJobId(job.id)} + /> + ))} +
+
+ {selectedJob && ( + setPendingDelete(selectedJob)} + onEdit={() => setEditor({ mode: 'edit', job: selectedJob })} + onOpenSession={onOpenSession} + onPauseResume={() => void handlePauseResume(selectedJob)} + onTrigger={() => void handleTrigger(selectedJob)} + /> + )} +
)} @@ -481,12 +540,48 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou ) } -function CronJobRow({ +function CronJobListRow({ + active, + c, + job, + onSelect +}: { + active: boolean + c: Translations['cron'] + job: CronJob + onSelect: () => void +}) { + const state = jobState(job) + + return ( + + ) +} + +function CronJobDetail({ busy, c, job, onDelete, onEdit, + onOpenSession, onPauseResume, onTrigger }: { @@ -495,32 +590,27 @@ function CronJobRow({ job: CronJob onDelete: () => void onEdit: () => void + onOpenSession?: (sessionId: string) => void onPauseResume: () => void onTrigger: () => void }) { const state = jobState(job) const isPaused = state === 'paused' - const hasName = Boolean(jobName(job)) - const prompt = jobPrompt(job) const deliver = jobDeliver(job) + const prompt = jobPrompt(job) return ( -
- - -
- - event.stopPropagation()} - title={jobTitle(job)} - /> - +
+ + + + +
+
+ +
+
+ ) +} + +function formatRunTime(seconds?: null | number): string { + if (!seconds) { + return '—' + } + + const date = new Date(seconds * 1000) + + return Number.isNaN(date.valueOf()) ? '—' : date.toLocaleString() +} + +// Runs are produced by the background scheduler tick (no UI signal), so poll +// while the panel is open + on tab re-focus so a fired run shows up within a few +// seconds instead of waiting for a reload. +const RUNS_POLL_INTERVAL_MS = 8000 + +function CronJobRuns({ + c, + jobId, + onOpenSession +}: { + c: Translations['cron'] + jobId: string + onOpenSession?: (sessionId: string) => void +}) { + const [runs, setRuns] = useState(null) + + useEffect(() => { + let cancelled = false + + const load = () => + getCronJobRuns(jobId) + .then(result => { + if (!cancelled) {setRuns(result)} + }) + .catch(() => { + if (!cancelled) {setRuns(prev => prev ?? [])} + }) + + void load() + + const intervalId = window.setInterval(() => { + if (document.visibilityState === 'visible') {void load()} + }, RUNS_POLL_INTERVAL_MS) + + const onVisible = () => { + if (document.visibilityState === 'visible') {void load()} + } + + document.addEventListener('visibilitychange', onVisible) + + return () => { + cancelled = true + window.clearInterval(intervalId) + document.removeEventListener('visibilitychange', onVisible) + } + }, [jobId]) + + return ( +
+
+ {c.runHistory} + {runs && runs.length > 0 ? ` · ${runs.length}` : ''} +
+ {runs === null ? ( +
+ +
+ ) : runs.length === 0 ? ( +
{c.noRuns}
+ ) : ( +
+ {runs.map(run => ( + + ))} +
+ )}
) } diff --git a/apps/desktop/src/app/cron/job-state.ts b/apps/desktop/src/app/cron/job-state.ts new file mode 100644 index 00000000000..10b90df6e7b --- /dev/null +++ b/apps/desktop/src/app/cron/job-state.ts @@ -0,0 +1,20 @@ +import type { CronJob } from '@/types/hermes' + +// Status-pip color per cron job state. Single source for the sidebar section and +// the Cron page so the two never drift. (Animation/size live at the call site.) +export const STATE_DOT: Record = { + completed: 'bg-(--ui-text-quaternary)', + disabled: 'bg-(--ui-text-quaternary)', + enabled: 'bg-primary', + error: 'bg-destructive', + paused: 'bg-amber-500', + running: 'bg-primary', + scheduled: 'bg-primary' +} + +// Effective state: explicit state wins; otherwise infer from the enabled flag. +export function jobState(job: CronJob): string { + const state = typeof job.state === 'string' ? job.state.trim() : '' + + return state || (job.enabled === false ? 'disabled' : 'scheduled') +} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 48f56b8077e..f02824e2925 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -11,8 +11,9 @@ import { Pane, PaneMain } from '@/components/pane-shell' import { useSkinCommand } from '@/themes/use-skin-command' import { formatRefValue } from '../components/assistant-ui/directive-text' -import { getSessionMessages, listAllProfileSessions, type SessionInfo } from '../hermes' +import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes' import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' +import { setCronFocusJobId, setCronJobs } from '../store/cron' import { $panesFlipped, $pinnedSessionIds, @@ -37,6 +38,7 @@ import { $selectedStoredSessionId, $sessions, $workingSessionIds, + CRON_SECTION_LIMIT, mergeSessionPage, sessionPinId, setAwaitingResponse, @@ -72,7 +74,7 @@ import { ModelVisibilityOverlay } from './model-visibility-overlay' import { RightSidebarPane } from './right-sidebar' import { $terminalTakeover } from './right-sidebar/store' import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent' -import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes' +import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes' import { useContextSuggestions } from './session/hooks/use-context-suggestions' import { useCwdActions } from './session/hooks/use-cwd-actions' import { useHermesConfig } from './session/hooks/use-hermes-config' @@ -103,9 +105,19 @@ const SettingsView = lazy(async () => ({ default: (await import('./settings')).S const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView })) // Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The -// section shows the most-recent jobs, not the full history (that lives in -// search), so this stays small and is fetched as a single bounded page. -const CRON_SECTION_LIMIT = 50 +// Cron sessions are written by a background scheduler tick (the desktop +// backend), so no user action signals the UI. Poll the bounded cron list on +// this cadence while the app is open + visible so new runs surface promptly +// instead of waiting for the next user-triggered refreshSessions(). +const CRON_POLL_INTERVAL_MS = 30_000 + +// Cheap signature compare so the poll only swaps the atom (and re-renders the +// sidebar) when the visible cron rows actually changed. +function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean { + if (a.length !== b.length) {return false} + + return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title) +} // Rows a session refresh must preserve even if the aggregator omits them: // in-flight first turns (message_count 0), pinned rows aged off the page, and @@ -231,20 +243,35 @@ export function DesktopController() { }, []) // Cron-job sessions as their own list (latest N). Independent of the recents - // page so the two never compete for slots. Cheap + bounded; refreshed - // alongside recents. + // page so the two never compete for slots. Cheap + bounded. Kept (even though + // the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run + // still resolves into the Pinned section via sessionByAnyId. const refreshCronSessions = useCallback(async () => { try { const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { source: 'cron' }) - setCronSessions(sessions) + setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions)) } catch { // Non-fatal: the cron section just stays empty/stale. } }, []) + // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created + // synchronously (agent tool call or the cron UI), so refreshing here right + // after an agent turn surfaces a new job immediately; the interval poll keeps + // next-run/state fresh as the scheduler advances them. + const refreshCronJobs = useCallback(async () => { + try { + const jobs = await getCronJobs() + + setCronJobs(jobs) + } catch { + // Non-fatal: the cron section just keeps its last-known jobs. + } + }, []) + const refreshSessions = useCallback(async () => { const requestId = refreshSessionsRequestRef.current + 1 refreshSessionsRequestRef.current = requestId @@ -277,7 +304,8 @@ export function DesktopController() { } void refreshCronSessions() - }, [refreshCronSessions]) + void refreshCronJobs() + }, [refreshCronSessions, refreshCronJobs]) const loadMoreSessions = useCallback(() => { bumpSessionsLimit() @@ -592,6 +620,25 @@ export function DesktopController() { } }, [gatewayState, refreshCurrentModel, refreshSessions]) + // Keep the cron jobs section live without a user action: the scheduler ticks + // in the background (advancing next-run/state and creating runs), so poll the + // job list on an interval (and on tab re-focus) while connected. + useEffect(() => { + if (gatewayState !== 'open') {return} + + const tick = () => { + if (document.visibilityState === 'visible') {void refreshCronJobs()} + } + + const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS) + document.addEventListener('visibilitychange', tick) + + return () => { + window.clearInterval(intervalId) + document.removeEventListener('visibilitychange', tick) + } + }, [gatewayState, refreshCronJobs]) + useRouteResume({ activeSessionId, activeSessionIdRef, @@ -632,9 +679,18 @@ export function DesktopController() { onDeleteSession={sessionId => void removeSession(sessionId)} onLoadMoreProfileSessions={loadMoreSessionsForProfile} onLoadMoreSessions={loadMoreSessions} + onManageCronJob={jobId => { + setCronFocusJobId(jobId) + navigate(CRON_ROUTE) + }} onNavigate={selectSidebarItem} onNewSessionInWorkspace={startSessionInWorkspace} onResumeSession={sessionId => navigate(sessionRoute(sessionId))} + onTriggerCronJob={jobId => { + void triggerCronJob(jobId) + .then(() => refreshCronJobs()) + .catch(() => undefined) + }} /> ) @@ -701,7 +757,10 @@ export function DesktopController() { {cronOpen && ( - + navigate(sessionRoute(sessionId))} + /> )} diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts index 442de939416..3618d8011fb 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts @@ -90,6 +90,7 @@ const TOOL_META: Record = { }, browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' }, clarify: { done: 'Asked a question', pending: 'Asking a question', icon: 'question', tone: 'agent' }, + cronjob: { done: 'Cron job', pending: 'Scheduling cron job', icon: 'watch', tone: 'agent' }, edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' }, execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' }, image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' }, @@ -899,6 +900,80 @@ function fallbackDetailText(args: unknown, result: unknown): string { return formatToolResultSummary(args) || minimalValueSummary(args) } +function cronScalar(value: unknown): string { + if (typeof value === 'string') return value.trim() + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + + return '' +} + +function formatCronTime(iso: string): string { + const ts = Date.parse(iso) + + if (Number.isNaN(ts)) return iso + + return new Date(ts).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) +} + +function cronjobSubtitle( + argsRecord: Record, + resultRecord: Record +): string { + const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null + + if (jobs) { + return jobs.length ? `${jobs.length} cron job${jobs.length === 1 ? '' : 's'}` : 'No cron jobs' + } + + const message = firstStringField(resultRecord, ['message']) + + if (message) return message + + const action = firstStringField(argsRecord, ['action']) || 'manage' + const name = firstStringField(resultRecord, ['name']) || firstStringField(argsRecord, ['name', 'job_id']) + const label = `${action[0]?.toUpperCase() ?? ''}${action.slice(1)}` + + return name ? `${label} ${name}` : `Cron ${action}` +} + +function cronjobDetail( + argsRecord: Record, + resultRecord: Record +): string { + const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null + + if (jobs) { + if (!jobs.length) return 'No cron jobs scheduled' + + return jobs + .slice(0, 20) + .map(job => { + const row = isRecord(job) ? job : {} + const name = firstStringField(row, ['name', 'id']) || 'job' + const sched = firstStringField(row, ['schedule_display', 'schedule']) + + return sched ? `- ${name} · ${sched}` : `- ${name}` + }) + .join('\n') + } + + const nextRun = cronScalar(resultRecord.next_run_at) + const rows: [string, string][] = [ + ['Schedule', cronScalar(resultRecord.schedule)], + ['Repeat', cronScalar(resultRecord.repeat)], + ['Delivery', cronScalar(resultRecord.deliver)], + ['Next run', nextRun ? formatCronTime(nextRun) : ''] + ] + const lines = rows.filter(([, value]) => value).map(([key, value]) => `${key}: ${value}`) + + return lines.length ? lines.join('\n') : fallbackDetailText(argsRecord, resultRecord) +} + function toolSubtitle( part: ToolPart, argsRecord: Record, @@ -992,6 +1067,10 @@ function toolSubtitle( return url ? hostnameOf(url) : 'Fetched webpage' } + if (toolName === 'cronjob') { + return cronjobSubtitle(argsRecord, resultRecord) + } + return ( compactPreview(formatToolResultSummary(part.result), 120) || compactPreview(resultRecord, 120) || @@ -1092,6 +1171,10 @@ function toolDetailText( .replace(/\bDuration\s+S\s*:/gi, 'Duration:') } + if (part.toolName === 'cronjob') { + return cronjobDetail(argsRecord, resultRecord) + } + return fallbackDetailText(argsRecord, resultRecord) } diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 20a4c805113..ce50fe98dae 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -32,6 +32,7 @@ import type { ProfileSetupCommand, ProfileSoul, ProfilesResponse, + SessionInfo, SessionMessagesResponse, SessionSearchResponse, SkillInfo, @@ -495,6 +496,14 @@ export function getCronJob(jobId: string): Promise { }) } +export async function getCronJobRuns(jobId: string, limit = 20): Promise { + const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({ + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}` + }) + + return runs ?? [] +} + export function createCronJob(body: CronJobCreatePayload): Promise { return window.hermesDesktop.api({ path: '/api/cron/jobs', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 29650b2d5ca..b1d9e52deb4 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -953,6 +953,11 @@ export const en: Translations = { emptyTitleSearch: 'No matches', last: 'Last:', next: 'Next:', + noRuns: 'No runs yet', + manage: 'Manage', + showRuns: 'Show runs', + hideRuns: 'Hide runs', + runHistory: 'Run history', actionsFor: title => `Actions for ${title}`, actionsTitle: 'Cron job actions', resume: 'Resume cron', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 4b6b120a409..5caeb67d303 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1055,6 +1055,11 @@ export const ja = defineLocale({ emptyTitleSearch: '一致なし', last: '前回', next: '次回', + noRuns: 'まだ実行されていません', + manage: '管理', + showRuns: '実行履歴を表示', + hideRuns: '実行履歴を隠す', + runHistory: '実行履歴', actionsFor: title => `${title} のアクション`, actionsTitle: 'Cron ジョブのアクション', resume: '再開', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index c5495fe4a93..2d2f39e3104 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -715,6 +715,11 @@ export interface Translations { emptyTitleSearch: string last: string next: string + noRuns: string + manage: string + showRuns: string + hideRuns: string + runHistory: string actionsFor: (title: string) => string actionsTitle: string resume: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 4051996d24b..bfae1b3d379 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1022,6 +1022,11 @@ export const zhHant = defineLocale({ emptyTitleSearch: '無相符項目', last: '上次:', next: '下次:', + noRuns: '尚無執行', + manage: '管理', + showRuns: '顯示執行記錄', + hideRuns: '隱藏執行記錄', + runHistory: '執行記錄', actionsFor: title => `${title} 的動作`, actionsTitle: '排程工作動作', resume: '繼續', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 262dc9afa3a..1701102e6ab 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1100,6 +1100,11 @@ export const zh: Translations = { emptyTitleSearch: '无匹配项', last: '上次:', next: '下次:', + noRuns: '尚无运行', + manage: '管理', + showRuns: '显示运行记录', + hideRuns: '隐藏运行记录', + runHistory: '运行记录', actionsFor: title => `${title} 的操作`, actionsTitle: '定时任务操作', resume: '恢复定时任务', diff --git a/apps/desktop/src/store/cron.ts b/apps/desktop/src/store/cron.ts new file mode 100644 index 00000000000..faa38472cca --- /dev/null +++ b/apps/desktop/src/store/cron.ts @@ -0,0 +1,15 @@ +import { atom } from 'nanostores' + +import type { CronJob } from '@/types/hermes' + +// Cron *jobs* (not run sessions) power the sidebar "Cron jobs" section. Listing +// the job — schedule, state, live next-run countdown — makes the job the +// first-class entity; its runs (sessions) resolve under it in the cron detail. +export const $cronJobs = atom([]) +export const setCronJobs = (jobs: CronJob[]) => $cronJobs.set(jobs) + +// One-shot focus target: clicking "Manage" on a job sets this, then opens the +// cron overlay, which reads it once to select + scroll to that job. Cleared +// after consumption so re-opening cron normally doesn't re-focus a stale job. +export const $cronFocusJobId = atom(null) +export const setCronFocusJobId = (id: null | string) => $cronFocusJobId.set(id) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 60f669a697c..3dfcb7ff12b 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -80,6 +80,11 @@ export const $sessionsTotal = atom(0) // scheduler's always-newest sessions never crowd recents out of the page // budget. Powers the collapsed "Cron jobs" sidebar section. export const $cronSessions = atom([]) +// Max cron sessions fetched for the sidebar section (single bounded page). When +// the fetch returns exactly this many rows we know more exist, so the section +// badge renders "N+". Lives here so the controller (fetch) and sidebar (badge) +// share one source of truth without a circular import. +export const CRON_SECTION_LIMIT = 50 // Listable conversation count per profile (children excluded), keyed by profile // name. Lets the sidebar scope its "Load more" footer to the active profile so a // huge default profile doesn't keep "Load more" visible while browsing a small diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 6bf554a98f0..2a0c279962d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5650,6 +5650,53 @@ async def get_cron_job(job_id: str, profile: Optional[str] = None): return job +@app.get("/api/cron/jobs/{job_id}/runs") +async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit: int = 20): + """Run sessions produced by a cron job, newest first. + + Cron runs are stored as ordinary sessions whose id is + ``cron_{job_id}_{timestamp}`` (see cron/scheduler.run_job). A job's history + is therefore every session whose id carries that prefix; ``source='cron'`` + narrows it and the id substring binds it to this job. Powers the run-history + list under each job in the desktop cron detail. Same row shape as + ``/api/sessions`` so the frontend can reuse SessionInfo. + """ + selected = profile or _find_cron_job_profile(job_id) + # job_id may be a human name; resolve to the canonical id used in run-session ids. + canonical = job_id + if selected: + job = _call_cron_for_profile(selected, "get_job", job_id) + if job and job.get("id"): + canonical = str(job["id"]) + + try: + limit_n = max(1, min(int(limit), 100)) + except (TypeError, ValueError): + limit_n = 20 + + db = _open_session_db_for_profile(selected) + try: + runs = db.list_sessions_rich( + source="cron", + id_query=f"cron_{canonical}_", + limit=limit_n, + offset=0, + order_by_last_active=True, + ) + now = time.time() + for s in runs: + s["is_active"] = ( + s.get("ended_at") is None + and (now - s.get("last_active", s.get("started_at", 0))) < 300 + ) + s["archived"] = bool(s.get("archived")) + if selected: + s["profile"] = selected + return {"runs": runs, "limit": limit_n} + finally: + db.close() + + @app.post("/api/cron/jobs") async def create_cron_job(body: CronJobCreate, profile: str = "default"): try: From ccaa5165a006d4748ffb404a9a5cfba8312a8b0e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 14:51:13 -0500 Subject: [PATCH 05/10] refactor(desktop): merge cron jobLabel/jobTitle into one shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar and Cron page each carried a near-identical name→prompt→id title fn. Collapse to a single jobTitle in cron/job-state.ts (the page variant, which also falls back to script then 'Cron job'). --- .../app/chat/sidebar/cron-jobs-section.tsx | 18 +++----------- apps/desktop/src/app/cron/index.tsx | 24 +------------------ apps/desktop/src/app/cron/job-state.ts | 9 +++++++ 3 files changed, 13 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx index a168b79eed2..7b0e7b95fe7 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -11,7 +11,7 @@ import { cn } from '@/lib/utils' import { $selectedStoredSessionId } from '@/store/session' import type { CronJob } from '@/types/hermes' -import { jobState, STATE_DOT } from '../../cron/job-state' +import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state' import { SidebarPanelLabel } from '../../shell/sidebar-label' const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused']) @@ -24,18 +24,6 @@ const PEEK_RUN_LIMIT = 5 // open peek so a freshly-fired run shows up within a few seconds. const PEEK_POLL_INTERVAL_MS = 8000 -function jobLabel(job: CronJob): string { - const name = (job.name ?? '').trim() - - if (name) {return name} - - const prompt = (job.prompt ?? '').trim() - - if (prompt) {return prompt.length > 60 ? `${prompt.slice(0, 60)}…` : prompt} - - return job.id -} - const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' }) // Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the @@ -126,7 +114,7 @@ export function SidebarCronJobsSection({ if (an !== null && bn === null) {return -1} - return jobLabel(a).localeCompare(jobLabel(b)) + return jobTitle(a).localeCompare(jobTitle(b)) }) }, [jobs]) @@ -191,7 +179,7 @@ function CronJobSidebarRow({ const c = t.cron const state = jobState(job) const next = nextRunMs(job) - const label = jobLabel(job) + const label = jobTitle(job) const meta = INACTIVE_STATES.has(state) ? (c.states[state] ?? state) diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index c7da8b91897..58967f9d64e 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -39,7 +39,7 @@ import { OverlayView } from '../overlays/overlay-view' import { PageSearchShell } from '../page-search-shell' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' -import { jobState, STATE_DOT } from './job-state' +import { jobState, jobTitle, STATE_DOT } from './job-state' const DEFAULT_DELIVER = 'local' @@ -84,28 +84,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) || '—' } diff --git a/apps/desktop/src/app/cron/job-state.ts b/apps/desktop/src/app/cron/job-state.ts index 10b90df6e7b..b7dd139cc4e 100644 --- a/apps/desktop/src/app/cron/job-state.ts +++ b/apps/desktop/src/app/cron/job-state.ts @@ -18,3 +18,12 @@ export function jobState(job: CronJob): string { 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' +} From de0469e02b1451d905f3d8cefd4eb5071874414c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 16:22:48 -0500 Subject: [PATCH 06/10] style(desktop): flatten cron overlay to match the overlay design pass De-box the master/detail Cron page ahead of #40708's flat-UI system: drop the two rounded-lg border/bg cards for a single --ui-stroke-tertiary hairline between list and detail, swap the header divider and schedule- preview chip onto the same stroke/bg-quinary tokens. No --stroke-nous (that lands with #40708); only tokens already on this branch. --- apps/desktop/src/app/cron/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 58967f9d64e..0e0c19e94d6 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -458,8 +458,8 @@ export function CronView({ {c.newCron}
-
-
+
+
{visibleJobs.map(job => ( ))}
-
+
{selectedJob && ( -
+
{jobTitle(job)} {c.states[state] ?? state} @@ -914,7 +914,7 @@ function CronEditorDialog({ {c.customHint} ) : ( -
+
{scheduleHint} {schedule} From b2bd31c724c193b31c0f18d045ea196357e34caf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 16:25:26 -0500 Subject: [PATCH 07/10] style(desktop): drop all borders from cron overlay Master/detail separated by gap, not a divider; header rule, schedule- preview chip border, and error-box border removed (subtle bg tints carry the grouping/semantics). Fully borderless to match the flat overlay pass. --- apps/desktop/src/app/cron/index.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 0e0c19e94d6..5da1c62b822 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -458,8 +458,8 @@ export function CronView({ {c.newCron}
-
-
+
+
{visibleJobs.map(job => ( ))}
-
+
{selectedJob && ( -
+
{jobTitle(job)} {c.states[state] ?? state} @@ -914,7 +914,7 @@ function CronEditorDialog({ {c.customHint} ) : ( -
+
{scheduleHint} {schedule} @@ -923,7 +923,7 @@ function CronEditorDialog({ )} {error && ( -
+
{error}
From f993d76874e859dbd96ab75e64d2e0fa9e640a94 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 16:39:56 -0500 Subject: [PATCH 08/10] refactor(desktop): converge cron overlay onto profiles' split layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cron's manage overlay now uses the shared OverlaySplitLayout (sidebar list + main detail) instead of a bespoke PageSearchShell + grid, matching profiles. Extract OverlayNewButton (the "+ New …" sidebar action) so profiles and cron share one component — its hover underline is scoped to the label span so it never strokes the leading icon glyph. --- apps/desktop/src/app/cron/index.tsx | 290 ++++++++---------- .../src/app/overlays/overlay-split-layout.tsx | 27 ++ apps/desktop/src/app/profiles/index.tsx | 13 +- 3 files changed, 151 insertions(+), 179 deletions(-) diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 5da1c62b822..63ca465fb77 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -14,6 +14,7 @@ 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 { @@ -35,8 +36,8 @@ import { $cronFocusJobId, setCronFocusJobId } 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 { jobState, jobTitle, STATE_DOT } from './job-state' @@ -243,17 +244,11 @@ interface CronViewProps extends React.ComponentProps<'section'> { setStatusbarItemGroup?: SetStatusbarItemGroup } -export function CronView({ - onClose, - onOpenSession, - setStatusbarItemGroup: _setStatusbarItemGroup, - ...props -}: CronViewProps) { +export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setStatusbarItemGroup }: CronViewProps) { const { t } = useI18n() const c = t.cron const [jobs, setJobs] = useState(null) const [query, setQuery] = useState('') - const [refreshing, setRefreshing] = useState(false) const [busyJobId, setBusyJobId] = useState(null) // Master/detail: the job whose schedule + run history fill the right pane. const [selectedJobId, setSelectedJobId] = useState(null) @@ -267,15 +262,11 @@ export function CronView({ const [deleting, setDeleting] = useState(false) const refresh = useCallback(async () => { - setRefreshing(true) - try { const result = await getCronJobs() setJobs(result) } catch (err) { notifyError(err, c.failedLoad) - } finally { - setRefreshing(false) } }, [c]) @@ -328,7 +319,6 @@ export function CronView({ }) }, [selectedJob]) - const enabledCount = jobs?.filter(job => job.enabled).length ?? 0 const totalCount = jobs?.length ?? 0 async function handlePauseResume(job: CronJob) { @@ -411,83 +401,62 @@ export function CronView({ return ( - void refresh()} - size="icon-xs" - title={refreshing ? c.refreshing : c.refresh} - type="button" - variant="ghost" - > - - - } - searchValue={query} - > - {!jobs ? ( - - ) : visibleJobs.length === 0 ? ( - // Empty state owns the primary "create" CTA — we used to also have - // one in the filters bar but it was redundant. Only show the button - // when there are zero jobs total; the search-empty case ("No - // matches") just asks the user to broaden their query. - setEditor({ mode: 'create' }) : undefined} - title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch} - /> - ) : ( - // Master/detail: job list on the left, the selected job's schedule, - // actions, and run history on the right. Replaces the old accordion - // (collapse-in-row inside a modal) — fewer clicks, no nested toggles. -
-
- - {c.active(enabledCount, totalCount)} - - -
-
-
- {visibleJobs.map(job => ( - setSelectedJobId(job.id)} - /> - ))} + {!jobs ? ( + + ) : ( + + + setEditor({ mode: 'create' })} /> + {totalCount > 0 && ( + + )} + {visibleJobs.map(job => ( + setSelectedJobId(job.id)} + /> + ))} + {visibleJobs.length === 0 && ( +

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

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

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

+
-
- {selectedJob && ( - setPendingDelete(selectedJob)} - onEdit={() => setEditor({ mode: 'edit', job: selectedJob })} - onOpenSession={onOpenSession} - onPauseResume={() => void handlePauseResume(selectedJob)} - onTrigger={() => void handleTrigger(selectedJob)} - /> - )} -
-
-
- )} - setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> + )} + + + )} + + setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}> @@ -513,7 +482,6 @@ export function CronView({ - ) } @@ -534,21 +502,21 @@ function CronJobListRow({ return ( ) } @@ -579,53 +547,66 @@ function CronJobDetail({ return (
-
-
- {jobTitle(job)} - {c.states[state] ?? state} - {deliver && deliver !== DEFAULT_DELIVER && ( - {c.deliveryLabels[deliver] ?? deliver} - )} +
+
+
+
+
+
+

{jobTitle(job)}

+ {c.states[state] ?? state} + {deliver && deliver !== DEFAULT_DELIVER && ( + {c.deliveryLabels[deliver] ?? deliver} + )} +
+
+ + + {jobScheduleDisplay(job)} + + + {c.last} {formatTime(job.last_run_at)} + + + {c.next} {formatTime(job.next_run_at)} + +
+
+
+ + + + +
+
+ + {prompt &&

{prompt}

} + {job.last_error && ( +

+ + {job.last_error} +

+ )} +
+ +
-
- - - {jobScheduleDisplay(job)} - - - {c.last} {formatTime(job.last_run_at)} - - - {c.next} {formatTime(job.next_run_at)} - -
- {prompt &&

{prompt}

} - {job.last_error && ( -

- - {job.last_error} -

- )} -
- - - - -
-
-
-
) @@ -731,33 +712,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 ( -
-
-
{title}
-

{description}

- {actionLabel && onAction && ( - - )} -
-
- ) -} - function CronEditorDialog({ editor, onClose, diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx index e713e4ea49e..fd562b40e28 100644 --- a/apps/desktop/src/app/overlays/overlay-split-layout.tsx +++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from 'react' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' import type { IconComponent } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -73,6 +75,31 @@ export function OverlayMain({ children, className }: OverlayMainProps) { ) } +// Boxless "+ New …" action that tops an OverlaySidebar list (profiles, cron, …). +// The text variant underlines on hover, which also strokes the icon glyph — so +// we keep the button itself underline-free and underline only the label span. +export function OverlayNewButton({ + icon = 'add', + label, + onClick +}: { + icon?: string + label: string + onClick: () => void +}) { + return ( + + ) +} + export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) { return ( + setCreateOpen(true)} /> {profiles.map(profile => ( Date: Sat, 6 Jun 2026 16:43:57 -0500 Subject: [PATCH 09/10] chore(desktop): drop dead cron i18n keys active/createFirst/refresh/refreshing went unused when the cron overlay moved to the shared split layout (no count header, no refresh button, no EmptyState CTA). Remove from types + all four locales. --- apps/desktop/src/i18n/en.ts | 4 ---- apps/desktop/src/i18n/ja.ts | 4 ---- apps/desktop/src/i18n/types.ts | 4 ---- apps/desktop/src/i18n/zh-hant.ts | 4 ---- apps/desktop/src/i18n/zh.ts | 4 ---- 5 files changed, 20 deletions(-) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 006cb27fd2e..0bbf2421b6c 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -889,8 +889,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', @@ -943,9 +941,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.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 5caeb67d303..a2e6d2075b2 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -991,8 +991,6 @@ export const ja = defineLocale({ cron: { close: 'Cron を閉じる', search: 'Cron ジョブを検索...', - refresh: 'Cron ジョブを更新', - refreshing: 'Cron ジョブを更新中', loading: 'Cron ジョブを読み込み中...', states: { enabled: '有効', @@ -1045,9 +1043,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: '検索キーワードを広げてください。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 2d2f39e3104..2a0186138e2 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -691,8 +691,6 @@ export interface Translations { cron: { close: string search: string - refresh: string - refreshing: string loading: string states: Record deliveryLabels: Record @@ -706,9 +704,7 @@ 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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index bfae1b3d379..d0cc1d61825 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -958,8 +958,6 @@ export const zhHant = defineLocale({ cron: { close: '關閉排程', search: '搜尋排程工作…', - refresh: '重新整理排程工作', - refreshing: '正在重新整理排程工作', loading: '正在載入排程工作…', states: { enabled: '已啟用', @@ -1012,9 +1010,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: '請嘗試更廣泛的搜尋詞。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 1701102e6ab..6238b4dee6d 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1037,8 +1037,6 @@ export const zh: Translations = { cron: { close: '关闭定时任务', search: '搜索定时任务…', - refresh: '刷新定时任务', - refreshing: '正在刷新定时任务', loading: '正在加载定时任务…', states: { enabled: '已启用', @@ -1091,9 +1089,7 @@ 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: '暂无排程任务', From 1238d08e0c9048c7aa7a869e5de43ee3ebcf4aee Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 16:47:46 -0500 Subject: [PATCH 10/10] fix(desktop): cron overlay mutations sync the sidebar instantly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manage overlay held its own local jobs list, so deleting/creating a job there left the sidebar's $cronJobs atom stale until the 30s poll (delete all → section lingered). Make the overlay read and mutate the shared atom directly (updateCronJobs), so sidebar + overlay are one source of truth and changes show immediately. --- apps/desktop/src/app/cron/index.tsx | 40 +++++++++++++++-------------- apps/desktop/src/store/cron.ts | 4 +++ 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 63ca465fb77..459c3fd558f 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -32,7 +32,7 @@ import { import { type Translations, useI18n } from '@/i18n' import { AlertTriangle, Clock } from '@/lib/icons' import { cn } from '@/lib/utils' -import { $cronFocusJobId, setCronFocusJobId } from '@/store/cron' +import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron' import { notify, notifyError } from '@/store/notifications' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' @@ -247,7 +247,11 @@ interface CronViewProps extends React.ComponentProps<'section'> { export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setStatusbarItemGroup }: CronViewProps) { const { t } = useI18n() const c = t.cron - const [jobs, setJobs] = useState(null) + // Source of truth is the shared atom (also fed by the controller poll), so the + // sidebar and this overlay never drift — a delete here clears the sidebar row + // immediately. `loading` only gates the first paint before the atom is filled. + const jobs = useStore($cronJobs) + const [loading, setLoading] = useState(jobs.length === 0) const [query, setQuery] = useState('') const [busyJobId, setBusyJobId] = useState(null) // Master/detail: the job whose schedule + run history fill the right pane. @@ -263,10 +267,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt const refresh = useCallback(async () => { try { - const result = await getCronJobs() - setJobs(result) + setCronJobs(await getCronJobs()) } catch (err) { notifyError(err, c.failedLoad) + } finally { + setLoading(false) } }, [c]) @@ -280,7 +285,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt // it, queue a scroll, then clear the one-shot focus so re-opening cron // normally doesn't re-trigger it. useEffect(() => { - if (!focusJobId || !jobs) {return} + if (!focusJobId) {return} const match = jobs.find(job => job.id === focusJobId || jobName(job) === focusJobId) @@ -292,13 +297,10 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt setCronFocusJobId(null) }, [focusJobId, jobs]) - const visibleJobs = useMemo(() => { - if (!jobs) { - return [] - } - - return jobs.filter(job => matchesQuery(job, query.trim())).sort((a, b) => jobTitle(a).localeCompare(jobTitle(b))) - }, [jobs, query]) + 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. @@ -319,7 +321,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt }) }, [selectedJob]) - const totalCount = jobs?.length ?? 0 + const totalCount = jobs.length async function handlePauseResume(job: CronJob) { setBusyJobId(job.id) @@ -327,7 +329,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt 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, @@ -345,7 +347,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt 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) @@ -363,7 +365,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt 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) { @@ -382,7 +384,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt 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, { @@ -392,7 +394,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt 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) }) } @@ -401,7 +403,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt return ( - {!jobs ? ( + {loading && jobs.length === 0 ? ( ) : ( diff --git a/apps/desktop/src/store/cron.ts b/apps/desktop/src/store/cron.ts index faa38472cca..2c492b34908 100644 --- a/apps/desktop/src/store/cron.ts +++ b/apps/desktop/src/store/cron.ts @@ -8,6 +8,10 @@ import type { CronJob } from '@/types/hermes' export const $cronJobs = atom([]) export const setCronJobs = (jobs: CronJob[]) => $cronJobs.set(jobs) +// In-place edit so the cron overlay's mutations (create/edit/delete/pause/…) +// land in the same atom the sidebar renders — no stale list until the next poll. +export const updateCronJobs = (fn: (jobs: CronJob[]) => CronJob[]) => $cronJobs.set(fn($cronJobs.get())) + // One-shot focus target: clicking "Manage" on a job sets this, then opens the // cron overlay, which reads it once to select + scroll to that job. Cleared // after consumption so re-opening cron normally doesn't re-focus a stale job.