From 9a0ebf017593dcd2379e7c568939ac884e3192a7 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 13 May 2026 08:21:43 -0400 Subject: [PATCH] feat(desktop): Cron, Profiles, usage analytics, and titlebar fixes - Add Cron and Profiles sidebar routes with full CRUD-style flows and API wiring. - Extend Command Center with auxiliary task overrides and a Usage panel (7d/30d/90d). - Fix titlebar geometry for WSL/Windows (native overlay width, tool spacing). - Remove stray merge conflict markers from pyproject.toml optional deps. Co-authored-by: Cursor --- apps/desktop/electron/main.cjs | 29 +- apps/desktop/src/app/chat/sidebar/index.tsx | 19 +- apps/desktop/src/app/command-center/index.tsx | 450 ++++++++++- apps/desktop/src/app/cron/index.tsx | 686 +++++++++++++++++ apps/desktop/src/app/desktop-controller.tsx | 21 + apps/desktop/src/app/profiles/index.tsx | 715 ++++++++++++++++++ apps/desktop/src/app/routes.ts | 26 +- apps/desktop/src/app/shell/app-shell.tsx | 23 +- apps/desktop/src/app/shell/titlebar.test.ts | 9 +- apps/desktop/src/app/shell/titlebar.ts | 20 +- apps/desktop/src/app/types.ts | 10 +- apps/desktop/src/global.d.ts | 2 + apps/desktop/src/hermes.ts | 139 ++++ apps/desktop/src/lib/icons.ts | 12 + apps/desktop/src/types/hermes.ts | 124 +++ package-lock.json | 107 ++- pyproject.toml | 5 - 17 files changed, 2277 insertions(+), 120 deletions(-) create mode 100644 apps/desktop/src/app/cron/index.tsx create mode 100644 apps/desktop/src/app/profiles/index.tsx diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index a4be33a332c..23b68c342c6 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -117,6 +117,14 @@ const WINDOW_BUTTON_POSITION = { x: 24, y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2 } +// Width Electron reserves for the Windows/Linux native min/max/close cluster +// when `titleBarOverlay` is enabled. The OS paints these buttons in the +// top-right corner of the renderer; we have to leave that much room on the +// right edge so our system tools (file browser, haptics, settings) don't sit +// underneath them. macOS uses left-side traffic lights instead and reports a +// position via getWindowButtonPosition(), so this width is non-zero only on +// non-macOS platforms. +const NATIVE_OVERLAY_BUTTON_WIDTH = 144 const APP_ICON_PATHS = [ path.join(APP_ROOT, 'public', 'apple-touch-icon.png'), path.join(APP_ROOT, 'dist', 'apple-touch-icon.png'), @@ -1840,9 +1848,18 @@ function getWindowButtonPosition() { return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION } +function getNativeOverlayWidth() { + // macOS reports traffic-light coords via windowButtonPosition; the + // titlebarOverlay there doesn't reserve right-edge space. Windows/Linux + // render the native window-controls overlay on the right, so the renderer + // needs to inset its right cluster by this much to clear them. + return IS_MAC ? 0 : NATIVE_OVERLAY_BUTTON_WIDTH +} + function getWindowState() { return { isFullscreen: Boolean(mainWindow?.isFullScreen?.()), + nativeOverlayWidth: getNativeOverlayWidth(), windowButtonPosition: getWindowButtonPosition() } } @@ -2481,8 +2498,16 @@ function createWindow() { minWidth: 900, minHeight: 620, title: 'Hermes', - titleBarStyle: IS_MAC ? 'hidden' : 'default', - titleBarOverlay: IS_MAC ? { height: TITLEBAR_HEIGHT } : undefined, + // Frameless title bar on every platform so the renderer can paint the + // "hide sidebar" button (and other left-side titlebar tools) flush with + // the top edge — matching the macOS layout where the traffic lights sit + // inside the same band. On Windows/Linux, titleBarOverlay tells Electron + // to paint native min/max/close in the top-right of the renderer; on + // macOS it just reserves a content inset alongside the traffic lights. + titleBarStyle: 'hidden', + titleBarOverlay: IS_MAC + ? { height: TITLEBAR_HEIGHT } + : { color: '#f7f7f7', height: TITLEBAR_HEIGHT, symbolColor: '#242424' }, trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, vibrancy: IS_MAC ? 'sidebar' : undefined, icon, diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 4b3e22fa2e1..68db3780032 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -14,7 +14,7 @@ import { } from '@/components/ui/sidebar' import { Skeleton } from '@/components/ui/skeleton' import type { SessionInfo } from '@/hermes' -import { Brain, ChevronDown, Layers3, MessageCircle, Plus, RefreshCw } from '@/lib/icons' +import { Brain, ChevronDown, Clock, Layers3, MessageCircle, Plus, RefreshCw, Users } from '@/lib/icons' import { cn } from '@/lib/utils' import { $pinnedSessionIds, @@ -28,7 +28,14 @@ import { } from '@/store/layout' import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session' -import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes' +import { + type AppView, + ARTIFACTS_ROUTE, + CRON_ROUTE, + MESSAGING_ROUTE, + PROFILES_ROUTE, + SKILLS_ROUTE +} from '../../routes' import { SidebarPanelLabel } from '../../shell/sidebar-label' import type { SidebarNavItem } from '../../types' @@ -43,7 +50,9 @@ const SIDEBAR_NAV: SidebarNavItem[] = [ }, { id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE }, { id: 'messaging', label: 'Messaging', icon: MessageCircle, route: MESSAGING_ROUTE }, - { id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE } + { id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }, + { id: 'cron', label: 'Cron', icon: Clock, route: CRON_ROUTE }, + { id: 'profiles', label: 'Profiles', icon: Users, route: PROFILES_ROUTE } ] interface ChatSidebarProps extends React.ComponentProps { @@ -117,7 +126,9 @@ export function ChatSidebar({ const active = (item.id === 'skills' && currentView === 'skills') || (item.id === 'messaging' && currentView === 'messaging') || - (item.id === 'artifacts' && currentView === 'artifacts') + (item.id === 'artifacts' && currentView === 'artifacts') || + (item.id === 'cron' && currentView === 'cron') || + (item.id === 'profiles' && currentView === 'profiles') return ( diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index a80d5ad06b7..321abc6a049 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -17,6 +17,7 @@ import { getGlobalModelOptions, getLogs, getStatus, + getUsageAnalytics, restartGateway, searchSessions, setModelAssignment, @@ -24,6 +25,7 @@ import { } from '@/hermes' import type { ActionStatusResponse, + AnalyticsResponse, AuxiliaryModelsResponse, ModelOptionProvider, SessionInfo, @@ -32,7 +34,7 @@ import type { } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' -import { Activity, AlertCircle, Cpu, Pin } from '@/lib/icons' +import { Activity, AlertCircle, BarChart3, Cpu, Pin } from '@/lib/icons' import { exportSession } from '@/lib/session-export' import { cn } from '@/lib/utils' import { upsertDesktopActionTask } from '@/store/activity' @@ -46,9 +48,33 @@ import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from import { OverlayView } from '../overlays/overlay-view' import { ARTIFACTS_ROUTE, MESSAGING_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes' -export type CommandCenterSection = 'models' | 'sessions' | 'system' +export type CommandCenterSection = 'models' | 'sessions' | 'system' | 'usage' -const SECTIONS = ['sessions', 'system', 'models'] as const satisfies readonly CommandCenterSection[] +const SECTIONS = ['sessions', 'system', 'models', 'usage'] as const satisfies readonly CommandCenterSection[] + +// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and +// hints make the assignments panel readable; raw task keys (vision, mcp, …) +// are opaque to most users. +interface AuxTaskMeta { + hint: string + key: string + label: string +} + +const AUX_TASKS: readonly AuxTaskMeta[] = [ + { key: 'vision', label: 'Vision', hint: 'Image analysis' }, + { key: 'web_extract', label: 'Web extract', hint: 'Page summarization' }, + { key: 'compression', label: 'Compression', hint: 'Context compaction' }, + { key: 'session_search', label: 'Session search', hint: 'Recall queries' }, + { key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' }, + { key: 'approval', label: 'Approval', hint: 'Smart auto-approve' }, + { key: 'mcp', label: 'MCP', hint: 'MCP tool routing' }, + { key: 'title_generation', label: 'Title gen', hint: 'Session titles' }, + { key: 'curator', label: 'Curator', hint: 'Skill-usage review' } +] + +const USAGE_PERIODS = [7, 30, 90] as const +type UsagePeriod = (typeof USAGE_PERIODS)[number] interface CommandCenterViewProps { initialSection?: CommandCenterSection @@ -62,13 +88,15 @@ interface CommandCenterViewProps { const SECTION_LABELS: Record = { sessions: 'Sessions', system: 'System', - models: 'Models' + models: 'Models', + usage: 'Usage' } const SECTION_DESCRIPTIONS: Record = { sessions: 'Search and manage sessions', system: 'Status, logs, and system actions', - models: 'Global and auxiliary model controls' + models: 'Global and auxiliary model controls', + usage: 'Token, cost, and skill activity over time' } interface NavigationSearchEntry { @@ -101,7 +129,8 @@ const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [ const SECTION_SEARCH_ENTRIES: readonly SectionSearchEntry[] = [ { id: 'section-sessions', section: 'sessions', title: 'Sessions panel', detail: 'Search, pin, and manage sessions' }, { id: 'section-system', section: 'system', title: 'System panel', detail: 'Gateway status, logs, restart/update' }, - { id: 'section-models', section: 'models', title: 'Models panel', detail: 'Main and auxiliary model assignments' } + { id: 'section-models', section: 'models', title: 'Models panel', detail: 'Main and auxiliary model assignments' }, + { id: 'section-usage', section: 'usage', title: 'Usage panel', detail: 'Token, cost, and skill activity' } ] interface SessionSearchHit { @@ -213,6 +242,12 @@ export function CommandCenterView({ const [selectedModel, setSelectedModel] = useState('') const [auxiliary, setAuxiliary] = useState(null) const [applyingModel, setApplyingModel] = useState(false) + const [editingAuxTask, setEditingAuxTask] = useState(null) + const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' }) + const [usagePeriod, setUsagePeriod] = useState(30) + const [usage, setUsage] = useState(null) + const [usageLoading, setUsageLoading] = useState(false) + const [usageError, setUsageError] = useState('') const searchRequestRef = useRef(0) const debouncedQuery = useDebouncedValue(query.trim(), 180) @@ -330,6 +365,23 @@ export function CommandCenterView({ } }, []) + const refreshUsage = useCallback( + async (days: UsagePeriod) => { + setUsageLoading(true) + setUsageError('') + + try { + const response = await getUsageAnalytics(days) + setUsage(response) + } catch (error) { + setUsageError(error instanceof Error ? error.message : String(error)) + } finally { + setUsageLoading(false) + } + }, + [] + ) + useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -392,6 +444,12 @@ export function CommandCenterView({ } }, [mainModel, modelsLoading, refreshModels, section]) + useEffect(() => { + if (section === 'usage') { + void refreshUsage(usagePeriod) + } + }, [refreshUsage, section, usagePeriod]) + useEffect(() => { if (!selectedProviderModels.length) { return @@ -500,6 +558,49 @@ export function CommandCenterView({ [mainModel, refreshModels] ) + const applyAuxiliaryDraft = useCallback( + async (task: string) => { + if (!auxDraft.provider || !auxDraft.model) { + return + } + + setApplyingModel(true) + setModelsError('') + + try { + await setModelAssignment({ + model: auxDraft.model, + provider: auxDraft.provider, + scope: 'auxiliary', + task + }) + setEditingAuxTask(null) + await refreshModels() + } catch (error) { + setModelsError(error instanceof Error ? error.message : String(error)) + } finally { + setApplyingModel(false) + } + }, + [auxDraft, refreshModels] + ) + + const beginAuxiliaryEdit = useCallback( + (task: string) => { + const current = auxiliary?.tasks.find(entry => entry.task === task) + const initialProvider = current?.provider && current.provider !== 'auto' ? current.provider : mainModel?.provider ?? '' + const initialModel = current?.model || mainModel?.model || '' + setAuxDraft({ provider: initialProvider, model: initialModel }) + setEditingAuxTask(task) + }, + [auxiliary, mainModel] + ) + + const auxDraftProviderModels = useMemo( + () => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [], + [auxDraft.provider, providers] + ) + const resetAuxiliaryModels = useCallback(async () => { if (!mainModel) { return @@ -562,7 +663,15 @@ export function CommandCenterView({ {SECTIONS.map(value => ( setSection(value)} @@ -582,6 +691,12 @@ export function CommandCenterView({ {systemLoading ? 'Refreshing...' : 'Refresh'} )} + {section === 'usage' && ( + void refreshUsage(usagePeriod)}> + + {usageLoading ? 'Refreshing...' : 'Refresh'} + + )} {section === 'models' && ( void refreshModels()}> @@ -733,6 +848,15 @@ export function CommandCenterView({ )} + ) : section === 'usage' ? ( + void refreshUsage(usagePeriod)} + period={usagePeriod} + usage={usage} + /> ) : section === 'system' ? (
@@ -858,25 +982,84 @@ export function CommandCenterView({
- {(auxiliary?.tasks || []).map(task => ( - -
-
{task.task}
-
- {task.provider} / {task.model} + {AUX_TASKS.map(meta => { + const current = auxiliary?.tasks.find(entry => entry.task === meta.key) + const isAuto = !current || !current.provider || current.provider === 'auto' + const isEditing = editingAuxTask === meta.key + + return ( + +
+
+
+ {meta.label} + {meta.hint} +
+
+ {isAuto + ? 'auto · use main model' + : `${current.provider} · ${current.model || '(provider default)'}`} +
+
+ {!isEditing && ( + <> + void setAuxiliaryToMain(meta.key)} + tone="subtle" + > + Set to main + + beginAuxiliaryEdit(meta.key)} + > + Change + + + )}
-
- void setAuxiliaryToMain(task.task)} - > - Set to main - - - ))} - {!auxiliary?.tasks?.length && ( -
No auxiliary assignments reported.
- )} + + {isEditing && ( +
+ + + void applyAuxiliaryDraft(meta.key)} + > + {applyingModel ? 'Applying...' : 'Apply'} + + setEditingAuxTask(null)} tone="subtle"> + Cancel + +
+ )} + + ) + })}
@@ -886,3 +1069,220 @@ export function CommandCenterView({ ) } + +function formatTokens(value: null | number | undefined): string { + const num = Number(value || 0) + + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M` + } + + if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}K` + } + + return num.toLocaleString() +} + +function formatCost(value: null | number | undefined): string { + const num = Number(value || 0) + + if (num === 0) { + return '$0.00' + } + + if (num < 0.01) { + return '<$0.01' + } + + return `$${num.toFixed(2)}` +} + +interface UsagePanelProps { + error: string + loading: boolean + onPeriodChange: (period: UsagePeriod) => void + onRefresh: () => void + period: UsagePeriod + usage: AnalyticsResponse | null +} + +function UsagePanel({ error, loading, onPeriodChange, onRefresh, period, usage }: UsagePanelProps) { + const daily = useMemo(() => usage?.daily ?? [], [usage]) + const totals = usage?.totals + const byModel = usage?.by_model ?? [] + const topSkills = usage?.skills?.top_skills ?? [] + + const maxTokens = useMemo(() => { + if (!daily.length) { + return 1 + } + + return daily.reduce((acc, entry) => Math.max(acc, (entry.input_tokens || 0) + (entry.output_tokens || 0)), 1) + }, [daily]) + + return ( +
+ +
+ {USAGE_PERIODS.map(value => ( + + ))} +
+ {error && ( + + + {error} + + )} +
+ + + {totals ? ( +
+ + + + 0 ? `actual ${formatCost(totals.total_actual_cost)}` : undefined} + label="Est. cost" + value={formatCost(totals.total_estimated_cost)} + /> +
+ ) : loading ? ( +
Loading usage...
+ ) : ( +
+ No usage in the last {period} days.{' '} + +
+ )} +
+ +
+ +
+ Daily tokens + + + input + + + output + + +
+ {daily.length === 0 ? ( +
No daily activity.
+ ) : ( + <> +
+ {daily.map(entry => { + const total = (entry.input_tokens || 0) + (entry.output_tokens || 0) + const inputH = Math.round(((entry.input_tokens || 0) / maxTokens) * 96) + const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96) + + return ( +
+
0 ? 1 : 0) }} + /> +
0 ? 1 : 0) }} + /> +
+ ) + })} +
+
+ {daily[0]?.day} + {daily[daily.length - 1]?.day} +
+ + )} + + + +
+
+
+ Top models +
+ {byModel.length === 0 ? ( +
No model usage yet.
+ ) : ( +
    + {byModel.slice(0, 6).map(entry => ( +
  • + {entry.model} + + {formatTokens((entry.input_tokens || 0) + (entry.output_tokens || 0))} ·{' '} + {formatCost(entry.estimated_cost)} + +
  • + ))} +
+ )} +
+ +
+
+ Top skills +
+ {topSkills.length === 0 ? ( +
No skill activity yet.
+ ) : ( +
    + {topSkills.slice(0, 6).map(entry => ( +
  • + {entry.skill} + + {entry.total_count.toLocaleString()} actions + +
  • + ))} +
+ )} +
+
+
+
+
+ ) +} + +function UsageStat({ hint, label, value }: { hint?: string; label: string; value: string }) { + return ( +
+
{label}
+
{value}
+ {hint &&
{hint}
} +
+ ) +} diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx new file mode 100644 index 00000000000..146dd3eedb3 --- /dev/null +++ b/apps/desktop/src/app/cron/index.tsx @@ -0,0 +1,686 @@ +import type * as React from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { PageLoader } from '@/components/page-loader' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { + createCronJob, + type CronJob, + deleteCronJob, + getCronJobs, + pauseCronJob, + resumeCronJob, + triggerCronJob, + updateCronJob +} from '@/hermes' +import { AlertTriangle, Clock, Pause, Pencil, Play, Plus, RefreshCw, Search, Trash2, X, Zap } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' + +import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' +import { titlebarHeaderBaseClass } from '../shell/titlebar' +import type { SetTitlebarToolGroup } from '../shell/titlebar-controls' + +const DEFAULT_DELIVER = 'local' + +const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [ + { label: 'This desktop', value: 'local' }, + { label: 'Telegram', value: 'telegram' }, + { label: 'Discord', value: 'discord' }, + { label: 'Slack', value: 'slack' }, + { label: 'Email', value: 'email' } +] + +const STATE_TONE: Record = { + enabled: 'good', + scheduled: 'good', + running: 'good', + paused: 'warn', + disabled: 'muted', + error: 'bad', + completed: 'muted' +} + +const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = { + good: 'bg-primary/10 text-primary', + muted: 'bg-muted text-muted-foreground', + warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300', + bad: 'bg-destructive/10 text-destructive' +} + +const asText = (value: unknown): string => (typeof value === 'string' ? value : '') + +const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value) + +function jobName(job: CronJob): string { + return asText(job.name).trim() +} + +function jobPrompt(job: CronJob): string { + return asText(job.prompt) +} + +function jobTitle(job: CronJob): string { + const name = jobName(job) + + if (name) { + return name + } + + const prompt = jobPrompt(job) + + if (prompt) { + return truncate(prompt, 60) + } + + const script = asText(job.script) + + if (script) { + return truncate(script, 60) + } + + return job.id || 'Cron job' +} + +function jobScheduleDisplay(job: CronJob): string { + return asText(job.schedule_display) || asText(job.schedule?.display) || asText(job.schedule?.expr) || '—' +} + +function jobScheduleExpr(job: CronJob): string { + return asText(job.schedule?.expr) || asText(job.schedule_display) || '' +} + +function jobState(job: CronJob): string { + return asText(job.state) || (job.enabled === false ? 'disabled' : 'scheduled') +} + +function jobDeliver(job: CronJob): string { + return asText(job.deliver) || DEFAULT_DELIVER +} + +function formatTime(iso?: null | string): string { + if (!iso) { + return '—' + } + + const date = new Date(iso) + + if (Number.isNaN(date.valueOf())) { + return iso + } + + return date.toLocaleString() +} + +function matchesQuery(job: CronJob, q: string): boolean { + if (!q) { + return true + } + + const needle = q.toLowerCase() + + return [jobTitle(job), jobPrompt(job), jobScheduleDisplay(job), jobScheduleExpr(job), jobDeliver(job)].some(value => + value.toLowerCase().includes(needle) + ) +} + +interface CronViewProps extends React.ComponentProps<'section'> { + setStatusbarItemGroup?: SetStatusbarItemGroup + setTitlebarToolGroup?: SetTitlebarToolGroup +} + +export function CronView({ + setStatusbarItemGroup: _setStatusbarItemGroup, + setTitlebarToolGroup, + ...props +}: CronViewProps) { + const [jobs, setJobs] = useState(null) + const [query, setQuery] = useState('') + const [refreshing, setRefreshing] = useState(false) + const [busyJobId, setBusyJobId] = useState(null) + + const [editor, setEditor] = useState({ mode: 'closed' }) + const [pendingDelete, setPendingDelete] = useState(null) + const [deleting, setDeleting] = useState(false) + + const refresh = useCallback(async () => { + setRefreshing(true) + + try { + const result = await getCronJobs() + setJobs(result) + } catch (err) { + notifyError(err, 'Failed to load cron jobs') + } finally { + setRefreshing(false) + } + }, []) + + useEffect(() => { + void refresh() + }, [refresh]) + + useEffect(() => { + if (!setTitlebarToolGroup) { + return + } + + setTitlebarToolGroup('cron', [ + { + disabled: refreshing, + icon: , + id: 'refresh-cron', + label: refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs', + onSelect: () => void refresh() + } + ]) + + return () => setTitlebarToolGroup('cron', []) + }, [refresh, refreshing, setTitlebarToolGroup]) + + const visibleJobs = useMemo(() => { + if (!jobs) { + return [] + } + + return jobs.filter(job => matchesQuery(job, query.trim())).sort((a, b) => jobTitle(a).localeCompare(jobTitle(b))) + }, [jobs, query]) + + const enabledCount = jobs?.filter(job => job.enabled).length ?? 0 + const totalCount = jobs?.length ?? 0 + + async function handlePauseResume(job: CronJob) { + setBusyJobId(job.id) + + try { + const isPaused = jobState(job) === 'paused' + const updated = isPaused ? await resumeCronJob(job.id) : await pauseCronJob(job.id) + setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current)) + notify({ + kind: 'success', + title: isPaused ? 'Cron resumed' : 'Cron paused', + message: truncate(jobTitle(job), 60) + }) + } catch (err) { + notifyError(err, 'Failed to update cron job') + } finally { + setBusyJobId(null) + } + } + + async function handleTrigger(job: CronJob) { + setBusyJobId(job.id) + + try { + const updated = await triggerCronJob(job.id) + setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current)) + notify({ kind: 'success', title: 'Cron triggered', message: truncate(jobTitle(job), 60) }) + } catch (err) { + notifyError(err, 'Failed to trigger cron job') + } finally { + setBusyJobId(null) + } + } + + async function handleConfirmDelete() { + if (!pendingDelete) { + return + } + + setDeleting(true) + + try { + await deleteCronJob(pendingDelete.id) + setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current)) + notify({ kind: 'success', title: 'Cron deleted', message: truncate(jobTitle(pendingDelete), 60) }) + setPendingDelete(null) + } catch (err) { + notifyError(err, 'Failed to delete cron job') + } finally { + setDeleting(false) + } + } + + 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 + }) + + setJobs(current => (current ? [...current, created] : [created])) + notify({ kind: 'success', title: 'Cron created', message: truncate(jobTitle(created), 60) }) + } else if (editor.mode === 'edit') { + const updated = await updateCronJob(editor.job.id, { + prompt: values.prompt, + schedule: values.schedule, + name: values.name, + deliver: values.deliver + }) + + setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current)) + notify({ kind: 'success', title: 'Cron updated', message: truncate(jobTitle(updated), 60) }) + } + + setEditor({ mode: 'closed' }) + } + + return ( +
+
+

Cron

+ + {totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`} + +
+ +
+
+
+ + +
+
+ + setQuery(event.target.value)} + placeholder="Search cron jobs..." + value={query} + /> + {query && ( + + )} +
+
+
+
+ + {!jobs ? ( + + ) : visibleJobs.length === 0 ? ( + setEditor({ mode: 'create' }) : undefined} + title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'} + /> + ) : ( +
+
+ {visibleJobs.map(job => ( + setPendingDelete(job)} + onEdit={() => setEditor({ mode: 'edit', job })} + onPauseResume={() => void handlePauseResume(job)} + onTrigger={() => void handleTrigger(job)} + /> + ))} +
+
+ )} +
+ + setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> + + !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}> + + + Delete cron job? + + {pendingDelete ? ( + <> + This will remove {truncate(jobTitle(pendingDelete), 60)}{' '} + permanently. It will stop firing immediately. + + ) : null} + + + + + + + + +
+ ) +} + +function CronJobRow({ + busy, + job, + onDelete, + onEdit, + onPauseResume, + onTrigger +}: { + busy: boolean + job: CronJob + onDelete: () => void + onEdit: () => void + onPauseResume: () => void + onTrigger: () => void +}) { + const state = jobState(job) + const isPaused = state === 'paused' + const hasName = Boolean(jobName(job)) + const prompt = jobPrompt(job) + const deliver = jobDeliver(job) + + return ( +
+ + +
+ + {isPaused ? : } + + + + + + + + + + +
+
+ ) +} + +function IconAction({ + children, + className, + ...props +}: Omit, 'size' | 'variant'>) { + return ( + + ) +} + +function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) { + return ( + + {children} + + ) +} + +function EmptyState({ + actionLabel, + description, + onAction, + title +}: { + actionLabel?: string + description: string + onAction?: () => void + title: string +}) { + return ( +
+
+
{title}
+

{description}

+ {actionLabel && onAction && ( + + )} +
+
+ ) +} + +function CronEditorDialog({ + editor, + onClose, + onSave +}: { + editor: EditorState + onClose: () => void + onSave: (values: EditorValues) => Promise +}) { + const open = editor.mode !== 'closed' + const isEdit = editor.mode === 'edit' + const initial = isEdit ? editor.job : null + + const [name, setName] = useState('') + const [prompt, setPrompt] = useState('') + const [schedule, setSchedule] = useState('') + const [deliver, setDeliver] = useState(DEFAULT_DELIVER) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!open) { + return + } + + setName(initial ? jobName(initial) : '') + setPrompt(initial ? jobPrompt(initial) : '') + setSchedule(initial ? jobScheduleExpr(initial) : '') + setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER) + setError(null) + setSaving(false) + }, [initial, open]) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + const trimmedPrompt = prompt.trim() + const trimmedSchedule = schedule.trim() + + if (!trimmedPrompt || !trimmedSchedule) { + setError('Prompt and schedule are required.') + + return + } + + setSaving(true) + setError(null) + + try { + await onSave({ + deliver, + name: name.trim(), + prompt: trimmedPrompt, + schedule: trimmedSchedule + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save cron job') + } finally { + setSaving(false) + } + } + + return ( + !value && !saving && onClose()} open={open}> + + + {isEdit ? 'Edit cron job' : 'New cron job'} + + {isEdit + ? 'Update the schedule, prompt, or delivery target. Changes apply on next run.' + : 'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".'} + + + +
+ + setName(event.target.value)} + placeholder="Morning briefing" + value={name} + /> + + + +