Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
233 changes: 221 additions & 12 deletions apps/desktop/src/app/cron/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,50 @@ const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
{ label: 'Email', value: 'email' }
]

const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
{
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<string, 'good' | 'muted' | 'warn' | 'bad'> = {
enabled: 'good',
scheduled: 'good',
Expand Down Expand Up @@ -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<string, string> = {
'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 'β€”'
Expand Down Expand Up @@ -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 | string>(null)
Expand All @@ -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()
Expand Down Expand Up @@ -601,21 +779,25 @@ function CronEditorDialog({
/>
</Field>

<div className="grid gap-4 sm:grid-cols-2">
<Field htmlFor="cron-schedule" label="Schedule">
<Input
className="font-mono"
id="cron-schedule"
onChange={event => setSchedule(event.target.value)}
placeholder="0 9 * * *"
value={schedule}
/>
<FieldHint>Cron expression, or phrases like "every hour" or "weekdays at 9am".</FieldHint>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="cron-frequency" label="Frequency">
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>

<Field htmlFor="cron-deliver" label="Deliver to">
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger id="cron-deliver">
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
Expand All @@ -629,6 +811,26 @@ function CronEditorDialog({
</Field>
</div>

{schedulePreset === 'custom' ? (
<Field htmlFor="cron-schedule" label="Custom schedule">
<Input
className="font-mono"
id="cron-schedule"
onChange={event => setSchedule(event.target.value)}
placeholder="0 9 * * * or weekdays at 9am"
value={schedule}
/>
<FieldHint>Cron expression, or phrases like "every hour" or "weekdays at 9am".</FieldHint>
</Field>
) : (
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<span className="font-medium text-foreground">{scheduleHint}</span>
<span className="font-mono text-muted-foreground">{schedule}</span>
</div>
</div>
)}

{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
Expand Down Expand Up @@ -684,3 +886,10 @@ interface EditorValues {
prompt: string
schedule: string
}

interface ScheduleOption {
expr?: string
hint: string
label: string
value: string
}
3 changes: 2 additions & 1 deletion apps/desktop/src/app/shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
return (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-120 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
'fixed inset-0 z-[120] pointer-events-auto bg-black/50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
data-slot="dialog-overlay"
Expand All @@ -46,7 +46,7 @@ function DialogContent({
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-130 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="dialog-content"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/ui/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function SelectContent({
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
'relative z-80 max-h-72 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'relative z-[140] max-h-72 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
Expand Down
Loading
Loading