mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
Merge pull request #25985 from NousResearch/austin/gui
feat: update cron modals
This commit is contained in:
commit
e5bbeb9f1e
8 changed files with 263 additions and 16 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
apps/desktop/src/global.d.ts
vendored
6
apps/desktop/src/global.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue