From 414cd314678e79f7f3f43999205d4a2d180cd7a5 Mon Sep 17 00:00:00 2001 From: digitalbase Date: Tue, 9 Jun 2026 08:47:55 +0200 Subject: [PATCH] fix(desktop): scope cron jobs sidebar to active profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop fetched /api/cron/jobs with no profile param, so the backend fell back to its "all" default and the bottom-left "Cron jobs" section showed every profile's jobs regardless of the active profile. Cron jobs are stored per-profile on disk and the REST backend already accepts a ?profile= query (default//all). Thread the active sidebar scope ($profileScope) through getCronJobs() in both the sidebar controller and the full cron page, mapping ALL_PROFILES -> "all". Also scope createCronJob() to the active profile so a job created while viewing a profile lands in — and shows up under — that profile instead of always defaulting to ~/.hermes. No backend change required; the endpoints were already profile-aware. --- apps/desktop/src/app/cron/index.tsx | 26 ++++++--- apps/desktop/src/app/desktop-controller.tsx | 8 ++- apps/desktop/src/hermes.test.ts | 65 ++++++++++++++++++++- apps/desktop/src/hermes.ts | 19 ++++-- 4 files changed, 103 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 459c3fd558f1..9e8f0add88fb 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -34,6 +34,7 @@ import { AlertTriangle, Clock } from '@/lib/icons' import { cn } from '@/lib/utils' import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron' import { notify, notifyError } from '@/store/notifications' +import { $profileScope, ALL_PROFILES } from '@/store/profile' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' @@ -251,6 +252,12 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt // 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) + // Cron jobs are per-profile; follow the same scope as the sidebar. A concrete + // profile lists (and creates) just that profile's jobs; ALL_PROFILES shows the + // unified view and creates fall back to the backend 'default'. + const profileScope = useStore($profileScope) + const cronListProfile = profileScope === ALL_PROFILES ? 'all' : profileScope + const cronCreateProfile = profileScope === ALL_PROFILES ? undefined : profileScope const [loading, setLoading] = useState(jobs.length === 0) const [query, setQuery] = useState('') const [busyJobId, setBusyJobId] = useState(null) @@ -267,13 +274,13 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt const refresh = useCallback(async () => { try { - setCronJobs(await getCronJobs()) + setCronJobs(await getCronJobs(cronListProfile)) } catch (err) { notifyError(err, c.failedLoad) } finally { setLoading(false) } - }, [c]) + }, [c, cronListProfile]) useRefreshHotkey(refresh) @@ -377,12 +384,15 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt async function handleEditorSave(values: EditorValues) { if (editor.mode === 'create') { - const created = await createCronJob({ - prompt: values.prompt, - schedule: values.schedule, - name: values.name || undefined, - deliver: values.deliver || DEFAULT_DELIVER - }) + const created = await createCronJob( + { + prompt: values.prompt, + schedule: values.schedule, + name: values.name || undefined, + deliver: values.deliver || DEFAULT_DELIVER + }, + cronCreateProfile + ) updateCronJobs(rows => [...rows, created]) notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) }) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index bd80fa269fc2..6a6266464415 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -286,13 +286,17 @@ export function DesktopController() { // next-run/state fresh as the scheduler advances them. const refreshCronJobs = useCallback(async () => { try { - const jobs = await getCronJobs() + // Scope to the active profile (cron jobs live per-profile on disk) so the + // sidebar shows only this profile's jobs; ALL_PROFILES → 'all' for the + // unified browse view. Single-profile users resolve to 'default'. + const cronProfile = profileScope === ALL_PROFILES ? 'all' : profileScope + const jobs = await getCronJobs(cronProfile) setCronJobs(jobs) } catch { // Non-fatal: the cron section just keeps its last-known jobs. } - }, []) + }, [profileScope]) const refreshSessions = useCallback(async () => { const requestId = refreshSessionsRequestRef.current + 1 diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index 0dcf58b36405..d7f3c650c90f 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { listAllProfileSessions, listSessions } from './hermes' +import { createCronJob, getCronJobs, listAllProfileSessions, listSessions } from './hermes' const emptySessionsResponse = { limit: 0, @@ -47,3 +47,66 @@ describe('Hermes REST session helpers', () => { ) }) }) + +describe('Hermes REST cron helpers', () => { + let api: ReturnType + + beforeEach(() => { + api = vi.fn().mockResolvedValue([]) + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { api } + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + Reflect.deleteProperty(window, 'hermesDesktop') + }) + + it('scopes the cron job listing to a concrete profile', async () => { + await getCronJobs('coder') + + expect(api).toHaveBeenCalledWith({ path: '/api/cron/jobs?profile=coder' }) + }) + + it('passes profile=all for the unified cron view', async () => { + await getCronJobs('all') + + expect(api).toHaveBeenCalledWith({ path: '/api/cron/jobs?profile=all' }) + }) + + it('omits the profile query when none is given (legacy default)', async () => { + await getCronJobs() + + expect(api).toHaveBeenCalledWith({ path: '/api/cron/jobs' }) + }) + + it('encodes profile names with reserved characters', async () => { + await getCronJobs('team a/b') + + expect(api).toHaveBeenCalledWith({ path: '/api/cron/jobs?profile=team%20a%2Fb' }) + }) + + it('creates a cron job in the given profile', async () => { + const body = { prompt: 'do thing', schedule: '0 9 * * *' } + await createCronJob(body, 'coder') + + expect(api).toHaveBeenCalledWith({ + path: '/api/cron/jobs?profile=coder', + method: 'POST', + body + }) + }) + + it('creates a cron job without a profile (backend defaults to default)', async () => { + const body = { prompt: 'do thing', schedule: '0 9 * * *' } + await createCronJob(body) + + expect(api).toHaveBeenCalledWith({ + path: '/api/cron/jobs', + method: 'POST', + body + }) + }) +}) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index da3247a36a94..cf134e857839 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -498,9 +498,15 @@ export function testMessagingPlatform(platformId: string): Promise { +// Cron jobs are stored per-profile (/cron/jobs.json). Pass a +// concrete profile key to list just that profile's jobs, or 'all' for the +// unified cross-profile view. Omitting the arg keeps the backend's legacy +// 'all' default, so non-profile callers are unaffected. +export function getCronJobs(profile?: string): Promise { + const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' + return window.hermesDesktop.api({ - path: '/api/cron/jobs' + path: `/api/cron/jobs${suffix}` }) } @@ -518,9 +524,14 @@ export async function getCronJobRuns(jobId: string, limit = 20): Promise { +// Create in a specific profile's store. Omitting `profile` lets the backend +// default to 'default' (~/.hermes). Callers viewing a concrete profile pass it +// so the new job lands in — and shows up under — the profile being viewed. +export function createCronJob(body: CronJobCreatePayload, profile?: string): Promise { + const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' + return window.hermesDesktop.api({ - path: '/api/cron/jobs', + path: `/api/cron/jobs${suffix}`, method: 'POST', body })