diff --git a/apps/desktop/src/app/cron/blueprints.tsx b/apps/desktop/src/app/cron/blueprints.tsx index fe5aeb5e250..e8e3d0fabb2 100644 --- a/apps/desktop/src/app/cron/blueprints.tsx +++ b/apps/desktop/src/app/cron/blueprints.tsx @@ -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 ( - - ) -} - -// 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 - } - - if (blueprints.isError) { - return - } - - if (cards.length === 0) { - return - } - - return ( - - {cards.map(blueprint => ( - onSetUp(blueprint)} /> - ))} - - ) -} diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 8dbab5be51b..726b11ad9fb 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -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 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 = [ { 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) const focusJobId = useStore($cronFocusJobId) - const [tab, setTab] = useState('jobs') const [editor, setEditor] = useState({ mode: 'closed' }) const [pendingDelete, setPendingDelete] = useState(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, - 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) { + 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 = ( - - ) - return ( - + - {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. - - setEditor({ - blueprint, - mode: 'blueprint', - profile: profileScope === ALL_PROFILES ? 'default' : profileScope - }) - } - /> - ) : loading && jobs.length === 0 ? ( + {loading && jobs.length === 0 ? ( ) : totalCount === 0 ? ( {isPaused ? c.resumeTitle : c.pauseTitle} - + {c.triggerNow} @@ -847,7 +808,7 @@ function CronEditorDialog({ onSave }: { editor: EditorState - onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record, profile: string) => Promise + onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record) => Promise onClose: () => void onSave: (values: EditorValues) => Promise }) { @@ -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>({}) + // 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) + // 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({ !value && !saving && onClose()} open={open}> - {isBlueprint ? blueprint?.title : isEdit ? c.editTitle : c.createTitle} - - {isBlueprint ? blueprint?.description || c.blueprints.dialogDesc : isEdit ? c.editDesc : c.createDesc} - + {isEdit ? c.editTitle : c.createTitle} + {isEdit ? c.editDesc : c.createDesc} + {!isEdit && blueprintList.length > 0 && ( + + + {blueprint?.description && {blueprint.description}} + + )} + {isBlueprint && blueprint ? (
{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 ( -
- - {children} -
- ) -} - -function FieldHint({ children }: { children: React.ReactNode }) { - return

{children}

-} - -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 diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx index cf0200275a6..6ed570f4b99 100644 --- a/apps/desktop/src/app/overlays/overlay-split-layout.tsx +++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx @@ -4,7 +4,9 @@ import { TabDropdown } from '@/components/ui/tab-dropdown' import type { IconComponent } from '@/lib/icons' import { cn } from '@/lib/utils' -import { PAGE_INSET_X, PAGE_MAX_W } from '../layout-constants' +import { PAGE_MAX_W } from '../layout-constants' + +import { OVERLAY_TOP_CLEARANCE } from './overlay-view' // The wide rail and the narrow dropdown swap at exactly the width where // OverlaySplitLayout drops to a single column, so the rail never stacks. @@ -57,10 +59,12 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) { return (