From fc21a40b79b43302648250e2f8ac572a4ea6560a Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 14 May 2026 18:54:58 -0400 Subject: [PATCH] feat: update cron modals --- apps/desktop/electron/main.cjs | 26 +++ apps/desktop/electron/preload.cjs | 1 + apps/desktop/src/app/cron/index.tsx | 233 ++++++++++++++++++++-- apps/desktop/src/app/shell/app-shell.tsx | 3 +- apps/desktop/src/components/ui/dialog.tsx | 4 +- apps/desktop/src/components/ui/select.tsx | 2 +- apps/desktop/src/global.d.ts | 6 + apps/desktop/src/themes/context.tsx | 4 + 8 files changed, 263 insertions(+), 16 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 483adbfffc1..099c11cceb6 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -132,11 +132,25 @@ const APP_ICON_PATHS = [ path.join(unpackedPathFor(APP_ROOT), 'dist', 'apple-touch-icon.png') ] +let rendererTitleBarTheme = null + +function isHexColor(value) { + return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) +} + function getTitleBarOverlayOptions() { if (IS_MAC) { return { height: TITLEBAR_HEIGHT } } + if (rendererTitleBarTheme) { + return { + color: rendererTitleBarTheme.background, + height: TITLEBAR_HEIGHT, + symbolColor: rendererTitleBarTheme.foreground + } + } + const useDarkColors = nativeTheme.shouldUseDarkColors return { @@ -2705,6 +2719,18 @@ ipcMain.handle('hermes:watchPreviewFile', (_event, url) => watchPreviewFile(Stri ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWatch(String(id || ''))) +ipcMain.on('hermes:titlebar-theme', (_event, payload) => { + if (!payload || !isHexColor(payload.background) || !isHexColor(payload.foreground)) { + return + } + + rendererTitleBarTheme = { + background: payload.background, + foreground: payload.foreground + } + mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions()) +}) + ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(url)) ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url)) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 7928b0fd2c2..de3dbeb8f93 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -27,6 +27,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir), watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url), stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id), + setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload), setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url), diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 146dd3eedb3..d3e156194b0 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -42,6 +42,50 @@ const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [ { label: 'Email', value: 'email' } ] +const SCHEDULE_OPTIONS: ReadonlyArray = [ + { + expr: '0 9 * * *', + hint: 'Every day at 9:00 AM', + label: 'Daily', + value: 'daily' + }, + { + expr: '0 9 * * 1-5', + hint: 'Monday through Friday at 9:00 AM', + label: 'Weekdays', + value: 'weekdays' + }, + { + expr: '0 9 * * 1', + hint: 'Every Monday at 9:00 AM', + label: 'Weekly', + value: 'weekly' + }, + { + expr: '0 9 1 * *', + hint: 'The first day of each month at 9:00 AM', + label: 'Monthly', + value: 'monthly' + }, + { + expr: '0 * * * *', + hint: 'At the top of every hour', + label: 'Hourly', + value: 'hourly' + }, + { + expr: '*/15 * * * *', + hint: 'Every 15 minutes', + label: 'Every 15 minutes', + value: 'every-15-minutes' + }, + { + hint: 'Cron syntax or natural language', + label: 'Custom', + value: 'custom' + } +] + const STATE_TONE: Record = { enabled: 'good', scheduled: 'good', @@ -109,6 +153,120 @@ function jobDeliver(job: CronJob): string { return asText(job.deliver) || DEFAULT_DELIVER } +function cronParts(expr: string): null | string[] { + const parts = expr.trim().replace(/\s+/g, ' ').split(' ') + + return parts.length === 5 ? parts : null +} + +function dayName(value: string): string { + const names: Record = { + '0': 'Sunday', + '1': 'Monday', + '2': 'Tuesday', + '3': 'Wednesday', + '4': 'Thursday', + '5': 'Friday', + '6': 'Saturday', + '7': 'Sunday' + } + + return names[value] ?? `day ${value}` +} + +function formatCronTime(minute: string, hour: string): string { + const numericHour = Number(hour) + const numericMinute = Number(minute) + + if (!Number.isInteger(numericHour) || !Number.isInteger(numericMinute)) { + return `${hour}:${minute}` + } + + return new Date(2000, 0, 1, numericHour, numericMinute).toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit' + }) +} + +function isIntegerToken(value: string): boolean { + return /^\d+$/.test(value) +} + +function scheduleOptionForExpr(expr: string): ScheduleOption { + const normalized = expr.trim().replace(/\s+/g, ' ') + const exactMatch = SCHEDULE_OPTIONS.find(option => option.expr === normalized) + + if (exactMatch) { + return exactMatch + } + + const parts = cronParts(normalized) + + if (!parts) { + return SCHEDULE_OPTIONS[SCHEDULE_OPTIONS.length - 1] + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts + + if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*' && isIntegerToken(minute) && isIntegerToken(hour)) { + return SCHEDULE_OPTIONS.find(option => option.value === 'daily') ?? SCHEDULE_OPTIONS[0] + } + + if (dayOfMonth === '*' && month === '*' && dayOfWeek === '1-5' && isIntegerToken(minute) && isIntegerToken(hour)) { + return SCHEDULE_OPTIONS.find(option => option.value === 'weekdays') ?? SCHEDULE_OPTIONS[0] + } + + if (dayOfMonth === '*' && month === '*' && isIntegerToken(dayOfWeek) && isIntegerToken(minute) && isIntegerToken(hour)) { + return SCHEDULE_OPTIONS.find(option => option.value === 'weekly') ?? SCHEDULE_OPTIONS[0] + } + + if (month === '*' && dayOfWeek === '*' && isIntegerToken(dayOfMonth) && isIntegerToken(minute) && isIntegerToken(hour)) { + return SCHEDULE_OPTIONS.find(option => option.value === 'monthly') ?? SCHEDULE_OPTIONS[0] + } + + if (hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*' && isIntegerToken(minute)) { + return SCHEDULE_OPTIONS.find(option => option.value === 'hourly') ?? SCHEDULE_OPTIONS[0] + } + + if (normalized === '*/15 * * * *') { + return SCHEDULE_OPTIONS.find(option => option.value === 'every-15-minutes') ?? SCHEDULE_OPTIONS[0] + } + + return SCHEDULE_OPTIONS[SCHEDULE_OPTIONS.length - 1] +} + +function scheduleSummary(option: ScheduleOption, expr: string): string { + const parts = cronParts(expr) + + if (!parts) { + return option.hint + } + + const [minute, hour, dayOfMonth, , dayOfWeek] = parts + + if (option.value === 'daily') { + return `Every day at ${formatCronTime(minute, hour)}` + } + + if (option.value === 'weekdays') { + return `Weekdays at ${formatCronTime(minute, hour)}` + } + + if (option.value === 'weekly') { + return `Every ${dayName(dayOfWeek)} at ${formatCronTime(minute, hour)}` + } + + if (option.value === 'monthly') { + return `Monthly on day ${dayOfMonth} at ${formatCronTime(minute, hour)}` + } + + if (option.value === 'hourly') { + return minute === '0' ? 'At the top of every hour' : `Every hour at :${minute.padStart(2, '0')}` + } + + return option.hint +} + function formatTime(iso?: null | string): string { if (!iso) { return '—' @@ -523,6 +681,7 @@ function CronEditorDialog({ const [name, setName] = useState('') const [prompt, setPrompt] = useState('') const [schedule, setSchedule] = useState('') + const [schedulePreset, setSchedulePreset] = useState('daily') const [deliver, setDeliver] = useState(DEFAULT_DELIVER) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) @@ -534,12 +693,31 @@ function CronEditorDialog({ setName(initial ? jobName(initial) : '') setPrompt(initial ? jobPrompt(initial) : '') - setSchedule(initial ? jobScheduleExpr(initial) : '') + setSchedule(initial ? jobScheduleExpr(initial) : (SCHEDULE_OPTIONS[0].expr ?? '')) + setSchedulePreset(initial ? scheduleOptionForExpr(jobScheduleExpr(initial)).value : 'daily') setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER) setError(null) setSaving(false) }, [initial, open]) + const selectedScheduleOption = + SCHEDULE_OPTIONS.find(candidate => candidate.value === schedulePreset) ?? SCHEDULE_OPTIONS[0] + + function handleSchedulePresetChange(nextPreset: string) { + setSchedulePreset(nextPreset) + setError(null) + + const option = SCHEDULE_OPTIONS.find(candidate => candidate.value === nextPreset) + + if (option?.expr) { + setSchedule(option.expr) + } else if (scheduleOptionForExpr(schedule).value !== 'custom') { + setSchedule('') + } + } + + const scheduleHint = scheduleSummary(selectedScheduleOption, schedule) + async function handleSubmit(event: React.FormEvent) { event.preventDefault() const trimmedPrompt = prompt.trim() @@ -601,21 +779,25 @@ function CronEditorDialog({ /> -
- - setSchedule(event.target.value)} - placeholder="0 9 * * *" - value={schedule} - /> - Cron expression, or phrases like "every hour" or "weekdays at 9am". +
+ + setSchedule(event.target.value)} + placeholder="0 9 * * * or weekdays at 9am" + value={schedule} + /> + Cron expression, or phrases like "every hour" or "weekdays at 9am". + + ) : ( +
+
+ {scheduleHint} + {schedule} +
+
+ )} + {error && (
@@ -684,3 +886,10 @@ interface EditorValues { prompt: string schedule: string } + +interface ScheduleOption { + expr?: string + hint: string + label: string + value: string +} diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index 09e8b853078..093bf1d7beb 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -64,6 +64,7 @@ export function AppShell({ // on macOS, where window controls sit on the left and are reported via // windowButtonPosition instead). The right tool cluster has to clear them. const nativeOverlayWidth = connection?.nativeOverlayWidth ?? 0 + const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem' const titlebarContentInset = sidebarOpen ? 0 @@ -116,7 +117,7 @@ export function AppShell({ '--titlebar-content-inset': `${titlebarContentInset}px`, '--titlebar-controls-left': `${titlebarControls.left}px`, '--titlebar-controls-top': `${titlebarControls.top}px`, - '--titlebar-tools-right': `calc(${nativeOverlayWidth}px + 0.75rem)`, + '--titlebar-tools-right': titlebarToolsRight, '--titlebar-tools-width': titlebarToolsWidth, // Anchor for the pane-tool cluster's right edge in TitlebarControls. // Sourced from the layout store rather than the PaneShell-emitted diff --git a/apps/desktop/src/components/ui/dialog.tsx b/apps/desktop/src/components/ui/dialog.tsx index f8f1c7f18d4..a54bf5da21d 100644 --- a/apps/desktop/src/components/ui/dialog.tsx +++ b/apps/desktop/src/components/ui/dialog.tsx @@ -24,7 +24,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps Promise watchPreviewFile: (url: string) => Promise stopPreviewFileWatch: (id: string) => Promise + setTitleBarTheme?: (payload: HermesTitleBarTheme) => void setPreviewShortcutActive?: (active: boolean) => void openExternal: (url: string) => Promise fetchLinkTitle: (url: string) => Promise @@ -111,6 +112,11 @@ export interface HermesConnection { windowButtonPosition: { x: number; y: number } | null } +export interface HermesTitleBarTheme { + background: string + foreground: string +} + export interface HermesWindowState { isFullscreen: boolean nativeOverlayWidth: number diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index e359834d887..d9476e40e15 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -195,6 +195,10 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') { set('--dt-font-sans', typo.fontSans) set('--dt-font-mono', typo.fontMono) set('--noise-opacity-mul', rendered === 'dark' ? 'calc(0.04 / 0.21)' : 'calc(0.34 / 0.21)') + window.hermesDesktop?.setTitleBarTheme?.({ + background: c.background, + foreground: c.foreground + }) if (typo.fontUrl && !INJECTED_FONT_URLS.has(typo.fontUrl)) { const link = document.createElement('link')