refactor(desktop): fold cron Blueprints into the New Job dialog

Blueprints lived behind a separate Jobs/Blueprints tab with its own card
gallery — a bespoke surface no other overlay uses. Remove the tab and make
blueprints a "Start from" dropdown at the top of the New Job dialog
(default "Custom" = the manual editor); picking one swaps the form for that
blueprint's typed slots. Also promote the detail-view "Trigger now" button
to a primary action and adopt the shared Field primitive.
This commit is contained in:
Brooklyn Nicholson 2026-07-24 19:16:45 -05:00
parent a3421aadda
commit ef6049c215
7 changed files with 83 additions and 175 deletions

View file

@ -1,16 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getAutomationBlueprints } from '@/hermes'
import type { AutomationBlueprint, AutomationBlueprintField } from '@/hermes'
import { useI18n } from '@/i18n'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { PanelDetail, PanelEmpty, PanelPill } from '../overlays/panel'
// The blueprint catalog is shared with the dashboard, so its deliver slot
// defaults to "origin" (the chat/home-channel a dashboard or gateway job was
@ -99,66 +89,3 @@ export function BlueprintSlotControl({
/>
)
}
// A clickable blueprint card — mirrors the app's other selectable cards
// (theme/pet/gateway/profile pickers) via selectableCardClass. Clicking opens
// the shared cron editor dialog pre-filled with this blueprint's slots; there's
// no inline expand form or divider.
function BlueprintCard({ blueprint, onSetUp }: { blueprint: AutomationBlueprint; onSetUp: () => void }) {
return (
<button
className={cn(selectableCardClass({ prominent: true }), 'w-full p-2 text-left')}
onClick={onSetUp}
type="button"
>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{blueprint.title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{blueprint.description}</p>
{blueprint.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{blueprint.tags.map(tag => (
<PanelPill key={tag}>{tag}</PanelPill>
))}
</div>
)}
</div>
</button>
)
}
// Automation Blueprints gallery — the desktop counterpart to the dashboard's
// blueprint tab. Each card opens the shared cron editor dialog pre-filled with
// the blueprint's typed slots; submitting POSTs to
// /api/cron/blueprints/instantiate, which fills the blueprint and creates the
// job via the same create_job path as a hand-written cron.
export function BlueprintsPanel({ onSetUp }: { onSetUp: (blueprint: AutomationBlueprint) => void }) {
const { t } = useI18n()
const c = t.cron
const blueprints = useQuery({
queryKey: ['cron-blueprints'],
queryFn: async () => (await getAutomationBlueprints()).blueprints
})
const cards = useMemo(() => blueprints.data ?? [], [blueprints.data])
if (blueprints.isLoading) {
return <PageLoader label={c.blueprints.loading} />
}
if (blueprints.isError) {
return <PanelEmpty description={c.blueprints.failedLoad} icon="warning" title={c.blueprints.failedLoad} />
}
if (cards.length === 0) {
return <PanelEmpty description={c.blueprints.emptyDesc} icon="lightbulb" title={c.blueprints.emptyTitle} />
}
return (
<PanelDetail>
{cards.map(blueprint => (
<BlueprintCard blueprint={blueprint} key={blueprint.key} onSetUp={() => onSetUp(blueprint)} />
))}
</PanelDetail>
)
}

View file

@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon, codiconIcon } from '@/components/ui/codicon'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
@ -14,8 +14,8 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field, FieldHint } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { SegmentedControl } from '@/components/ui/segmented-control'
import {
Select,
SelectContent,
@ -32,6 +32,7 @@ import {
type CronDeliveryTarget,
type CronJob,
deleteCronJob,
getAutomationBlueprints,
getCronDeliveryTargets,
getCronJobRuns,
getCronJobs,
@ -70,27 +71,20 @@ import {
} from '../overlays/panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import {
BlueprintSlotControl,
blueprintSlotHelp,
BlueprintsPanel,
cleanBlueprintFieldError,
initialBlueprintValues
} from './blueprints'
import { BlueprintSlotControl, blueprintSlotHelp, cleanBlueprintFieldError, initialBlueprintValues } from './blueprints'
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
import { jobState, jobTitle, STATE_DOT } from './job-state'
const DEFAULT_DELIVER = 'local'
// Two surfaces share the cron panel: the live Jobs list and the Blueprints
// gallery (parameterized templates that instantiate a real job). The active tab
// is pure view state — it lives here, not in a store.
type CronTab = 'blueprints' | 'jobs'
// Radix <SelectItem> rejects empty-string values, so the "no override" row in
// the model picker carries this sentinel and is mapped back to '' on save.
const MODEL_DEFAULT_VALUE = '__default__'
// "Start from" default: the manual editor (blank cron). Any other value is a
// blueprint key. Blueprint keys never collide with this sentinel.
const CUSTOM_TEMPLATE = 'custom'
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
{ expr: '0 9 * * *', value: 'daily' },
{ expr: '0 9 * * 1-5', value: 'weekdays' },
@ -305,7 +299,6 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
const pendingScrollRef = useRef<null | string>(null)
const focusJobId = useStore($cronFocusJobId)
const [tab, setTab] = useState<CronTab>('jobs')
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)
@ -455,12 +448,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
// Blueprint instantiation is a distinct backend path (fills typed slots, then
// creates the job) so it can't share the raw-cron onSave contract. Merge the
// created job into $cronJobs like every other create path.
async function handleBlueprintCreate(
blueprint: AutomationBlueprint,
values: Record<string, string>,
profile: string
) {
// created job into $cronJobs like every other create path. A blueprint writes a
// real per-profile job, and "all" is not a writable target — collapse it to
// 'default', matching the manual create path in handleEditorSave.
async function handleBlueprintCreate(blueprint: AutomationBlueprint, values: Record<string, string>) {
const profile = profileScope === ALL_PROFILES ? 'default' : profileScope
const job = await instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile)
updateCronJobs(rows => {
@ -472,42 +464,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
setEditor({ mode: 'closed' })
}
const tabToggle = (
<SegmentedControl
onChange={setTab}
options={[
{ icon: codiconIcon('watch'), id: 'jobs', label: c.tabs.jobs },
{ icon: codiconIcon('lightbulb'), id: 'blueprints', label: c.blueprints.tab }
]}
value={tab}
/>
)
return (
<Panel closeLabel={c.close} onClose={onClose}>
<PanelHeader
actions={tabToggle}
subtitle={tab === 'jobs' ? c.count(totalCount) : c.blueprints.subtitle}
title={c.title}
/>
<PanelHeader subtitle={c.count(totalCount)} title={c.title} />
{tab === 'blueprints' ? (
// A blueprint instantiates a real per-profile job, and "all" is not a
// writable target — collapse it to 'default', matching the create path
// in handleEditorSave. A user scoped to all profiles gets the job in
// 'default'. The gallery is a single scroll column, so it renders
// directly (BlueprintsPanel uses PanelDetail) rather than in PanelBody's
// master/detail row.
<BlueprintsPanel
onSetUp={blueprint =>
setEditor({
blueprint,
mode: 'blueprint',
profile: profileScope === ALL_PROFILES ? 'default' : profileScope
})
}
/>
) : loading && jobs.length === 0 ? (
{loading && jobs.length === 0 ? (
<PageLoader label={c.loading} />
) : totalCount === 0 ? (
<PanelEmpty
@ -663,7 +624,7 @@ function CronJobDetail({
<PanelAction disabled={busy} icon={isPaused ? 'play' : 'debug-pause'} onClick={onPauseResume}>
{isPaused ? c.resumeTitle : c.pauseTitle}
</PanelAction>
<PanelAction disabled={busy} icon="zap" onClick={onTrigger}>
<PanelAction disabled={busy} icon="zap" onClick={onTrigger} primary>
{c.triggerNow}
</PanelAction>
</div>
@ -847,7 +808,7 @@ function CronEditorDialog({
onSave
}: {
editor: EditorState
onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record<string, string>, profile: string) => Promise<void>
onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record<string, string>) => Promise<void>
onClose: () => void
onSave: (values: EditorValues) => Promise<void>
}) {
@ -855,8 +816,6 @@ function CronEditorDialog({
const c = t.cron
const open = editor.mode !== 'closed'
const isEdit = editor.mode === 'edit'
const isBlueprint = editor.mode === 'blueprint'
const blueprint = isBlueprint ? editor.blueprint : null
const initial = isEdit ? editor.job : null
const scriptOnlyJob = initial ? jobIsScriptOnly(initial) : false
@ -868,14 +827,33 @@ function CronEditorDialog({
// Per-job model override, encoded as `${providerSlug}:${model}` (split on the
// first ':' when saving). MODEL_DEFAULT_VALUE = follow the global default.
const [modelChoice, setModelChoice] = useState(MODEL_DEFAULT_VALUE)
// Blueprint mode fills typed slots (time/enum/weekdays/text) instead of the
// raw cron fields; the backend renders the prompt + schedule from them.
// Blueprint fills typed slots (time/enum/weekdays/text) instead of the raw
// cron fields; the backend renders the prompt + schedule from them.
const [slotValues, setSlotValues] = useState<Record<string, string>>({})
// Create mode can start from a ready-made blueprint instead of a blank cron.
// CUSTOM_TEMPLATE (default) = the manual editor; any other value is a
// blueprint key that swaps the form for that blueprint's typed slots.
const [templateChoice, setTemplateChoice] = useState(CUSTOM_TEMPLATE)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// The blueprint catalog powers the create dialog's "Start from" dropdown; it's
// meaningless when editing an existing job, so skip the fetch there.
const blueprintsQuery = useQuery({
queryKey: ['cron-blueprints'],
queryFn: async () => (await getAutomationBlueprints()).blueprints,
enabled: open && !isEdit
})
const blueprintList = blueprintsQuery.data ?? []
const blueprint =
templateChoice === CUSTOM_TEMPLATE ? null : (blueprintList.find(item => item.key === templateChoice) ?? null)
const isBlueprint = blueprint !== null
// Same catalog the chat model picker uses: configured providers and their
// actually-available models only. Script-only + blueprint dialogs never pick a
// actually-available models only. Script-only + blueprint forms never pick a
// model here, so skip the fetch entirely for them.
const modelOptions = useQuery({
queryKey: ['model-options', 'global'],
@ -903,10 +881,18 @@ function CronEditorDialog({
setSchedulePreset(initial ? scheduleOptionForExpr(jobScheduleExpr(initial)).value : 'daily')
setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER)
setModelChoice(initial && jobModel(initial) ? `${jobProvider(initial)}:${jobModel(initial)}` : MODEL_DEFAULT_VALUE)
setSlotValues(blueprint ? initialBlueprintValues(blueprint) : {})
setSlotValues({})
setTemplateChoice(CUSTOM_TEMPLATE)
setError(null)
setSaving(false)
}, [blueprint, initial, open])
}, [initial, open])
// Seed the typed slots with the blueprint's defaults whenever a blueprint is
// picked from "Start from" (and reset them when switching back to Custom).
useEffect(() => {
setSlotValues(blueprint ? initialBlueprintValues(blueprint) : {})
setError(null)
}, [blueprint])
const selectedScheduleOption =
SCHEDULE_OPTIONS.find(candidate => candidate.value === schedulePreset) ?? SCHEDULE_OPTIONS[0]
@ -988,7 +974,7 @@ function CronEditorDialog({
async function handleBlueprintSubmit(event: React.FormEvent) {
event.preventDefault()
if (!isBlueprint) {
if (!blueprint) {
return
}
@ -996,7 +982,7 @@ function CronEditorDialog({
setError(null)
try {
await onBlueprintCreate(editor.blueprint, slotValues, editor.profile)
await onBlueprintCreate(blueprint, slotValues)
} catch (err) {
// 422 carries the slot-level validation message; surface it inline.
setError(cleanBlueprintFieldError(err instanceof Error ? err.message : String(err)))
@ -1009,12 +995,29 @@ function CronEditorDialog({
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{isBlueprint ? blueprint?.title : isEdit ? c.editTitle : c.createTitle}</DialogTitle>
<DialogDescription>
{isBlueprint ? blueprint?.description || c.blueprints.dialogDesc : isEdit ? c.editDesc : c.createDesc}
</DialogDescription>
<DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
</DialogHeader>
{!isEdit && blueprintList.length > 0 && (
<Field htmlFor="cron-template" label={c.blueprints.startFrom}>
<Select onValueChange={setTemplateChoice} value={templateChoice}>
<SelectTrigger className="h-9 rounded-md" id="cron-template">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={CUSTOM_TEMPLATE}>{c.blueprints.custom}</SelectItem>
{blueprintList.map(item => (
<SelectItem key={item.key} value={item.key}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
{blueprint?.description && <FieldHint>{blueprint.description}</FieldHint>}
</Field>
)}
{isBlueprint && blueprint ? (
<form className="grid gap-4" onSubmit={handleBlueprintSubmit}>
{blueprint.fields.map(field => {
@ -1192,39 +1195,7 @@ function CronEditorDialog({
)
}
function Field({
children,
htmlFor,
label,
optional,
optionalLabel
}: {
children: React.ReactNode
htmlFor: string
label: string
optional?: boolean
optionalLabel?: string
}) {
return (
<div className="grid gap-1.5">
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
{label}
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>}
</label>
{children}
</div>
)
}
function FieldHint({ children }: { children: React.ReactNode }) {
return <p className="text-[0.66rem] leading-4 text-muted-foreground">{children}</p>
}
type EditorState =
| { blueprint: AutomationBlueprint; mode: 'blueprint'; profile: string }
| { job: CronJob; mode: 'edit' }
| { mode: 'closed' }
| { mode: 'create' }
type EditorState = { job: CronJob; mode: 'edit' } | { mode: 'closed' } | { mode: 'create' }
interface EditorValues {
deliver: string

View file

@ -1688,6 +1688,8 @@ export const en: Translations = {
},
blueprints: {
tab: 'Blueprints',
startFrom: 'Start from',
custom: 'Custom',
subtitle: 'Ready-made automations',
dialogDesc: 'Fill in the details and schedule it.',
scheduleIt: 'Schedule it',

View file

@ -1560,6 +1560,8 @@ export const ja = defineLocale({
},
blueprints: {
tab: 'ブレーンプリント',
startFrom: '開始点',
custom: 'カスタム',
subtitle: 'すぐに使える自動化',
dialogDesc: '詳細を入力してスケジュールします。',
scheduleIt: 'スケジュールする',

View file

@ -1399,6 +1399,8 @@ export interface Translations {
}
blueprints: {
tab: string
startFrom: string
custom: string
subtitle: string
dialogDesc: string
scheduleIt: string

View file

@ -1509,6 +1509,8 @@ export const zhHant = defineLocale({
},
blueprints: {
tab: '藍圖',
startFrom: '從此開始',
custom: '自訂',
subtitle: '現成的自動化',
dialogDesc: '填寫詳細資訊並進行排程。',
scheduleIt: '安排工作',

View file

@ -1879,6 +1879,8 @@ export const zh: Translations = {
},
blueprints: {
tab: '蓝图',
startFrom: '从此开始',
custom: '自定义',
subtitle: '现成的自动化',
dialogDesc: '填写详细信息并进行排程。',
scheduleIt: '安排任务',