Merge pull request #25985 from NousResearch/austin/gui

feat: update cron modals
This commit is contained in:
Austin Pickett 2026-05-14 19:00:18 -04:00 committed by GitHub
commit e5bbeb9f1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 263 additions and 16 deletions

View file

@ -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))

View file

@ -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),

View file

@ -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',
@ -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 '—'
@ -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)
@ -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({
/>
</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>
@ -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" />
@ -684,3 +886,10 @@ interface EditorValues {
prompt: string
schedule: string
}
interface ScheduleOption {
expr?: string
hint: string
label: string
value: string
}

View file

@ -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

View file

@ -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"
@ -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"

View file

@ -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

View file

@ -23,6 +23,7 @@ declare global {
normalizePreviewTarget: (target: string, baseDir?: string) => Promise<HermesPreviewTarget | null>
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
stopPreviewFileWatch: (id: string) => Promise<boolean>
setTitleBarTheme?: (payload: HermesTitleBarTheme) => void
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
fetchLinkTitle: (url: string) => Promise<string>
@ -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

View file

@ -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')