mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
feat(desktop): add Cron Blueprints to the GUI (#70066)
* feat(desktop): add Cron Blueprints to the GUI
The desktop app had a Cron jobs panel but no Blueprints tab, so the
parameterized automation templates the dashboard offers were unreachable
there (parity matrix: Dashboard=Y, GUI=N).
Adds a Jobs/Blueprints segmented toggle to the cron panel. The Blueprints
tab renders the catalog from the existing GET /api/cron/blueprints endpoint,
one card per template with a typed form (time/enum/weekdays/text slots).
Submitting POSTs to /api/cron/blueprints/instantiate, which fills the slots
and creates a real cron job via the same create_job path as a hand-written
one. The new job is merged into the shared atom so the Jobs tab
and sidebar reflect it immediately.
Backend already served both endpoints; this is desktop frontend only.
Strings added to all four locales.
* fix(desktop): stop cron blueprint cards clipping and tabs overlapping close X
Blueprint cards were wrapped in PanelBlock (a max-h-48 overflow-auto <pre>
for monospace code), which capped each card and forced an inner scroll,
clipping the copy. Use a plain auto-height card div so rows grow with their
content and the gallery scrolls as one.
PanelHeader actions sat under the overlay's absolutely-positioned close X
(no layout space reserved). Reserve pr-8 clearance when actions are present.
* refactor(desktop): narrow blueprint card i18n dep, document intentional scoping
Address review nits on the Cron Blueprints PR:
- BlueprintCard's submit useCallback depended on the whole t.cron object; it
only uses the blueprints slice. Bind const b = c.blueprints and use it (plus
narrow the dep) throughout the card.
- Document the intentional GET-vs-POST profile asymmetry on the blueprint
endpoints (global catalog vs per-profile instantiate) — the prior comment
claimed both were profile-scoped.
- Note why the blueprints tab collapses 'all' scope to 'default' (a blueprint
creates a real per-profile job; 'all' is not a writable target).
* fix(desktop): default blueprint delivery to This desktop, not origin
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
created from). Desktop has no origin chat and no home-channel picker, so the
seeded 'origin' rendered unlabeled and, at delivery, fell through the
home-channel fallback to nowhere when no gateway was configured.
Seed the deliver slot to 'local' (This desktop) when the backend default is
'origin' or empty, drop the origin option from the desktop dropdown, and label
the remaining options with the desktop's own delivery labels — matching the
manual cron editor (local/telegram/discord/slack/email). Also skip the
backend's origin-centric deliver help, which contradicts desktop semantics.
* refactor(desktop): align blueprint cards with the Panel/settings idiom
Address PR review (UI consistency with neighboring surfaces):
- Card container: drop the standalone-card look (bg-foreground/5) for the
shared in-panel grouping token bg-(--ui-bg-quinary), matching the cron
editor's in-surface groupings so blueprints sit in the Panel family.
- Form fields: replace the bespoke <label>+<Input> rows with the shared
ListRow primitive from settings/primitives (label+help on the left, control
on the right, stacks in a narrow pane) — same idiom as settings/messaging.
No behavior change; blueprint deliver remap and $cronJobs merge untouched.
* fix(desktop): satisfy eslint on the cron blueprint files
CI check:lint failed on import/export ordering and an unused import in the
blueprint changes:
- hermes.ts: sort AutomationBlueprint before AuxiliaryModelsResponse in the
type import + re-export blocks, and drop the unused AutomationBlueprintField
import (still re-exported for blueprints.tsx).
- cron/index.tsx: alphabetize the dialog/segmented-control imports and the
./blueprints vs ../shell/statusbar-controls group.
- blueprints.tsx: add the required blank lines between statements.
eslint --fix only; no behavior change. typecheck + blueprint tests green.
* refactor(desktop): blueprints reuse the cron editor dialog + shared card
Address review: the blueprint UI was still going its own way on the card and
form. Reuse the app's canonical pieces instead of a bespoke surface.
- CronEditorDialog gains a 'blueprint' mode: EditorState carries the blueprint
+ target profile, the dialog renders the typed slots with the same
Field/FieldHint/DialogFooter/error-block chrome as manual New cron, and
submit routes to instantiateAutomationBlueprint. One dialog, one editor state
machine. Resolves the accordion, the border-t divider, ListRow-vs-Field, the
ad-hoc buttons, and the plain error <p> in one move.
- Blueprint cards use selectableCardClass({ prominent: true }) (the shared
theme/pet/gateway card idiom), caller owns padding (p-2), whole card is a
button that opens the dialog pre-filled. No inline form.
- Gallery renders via PanelDetail, not PanelBody's master/detail row.
- i18n: drop the now-unused blueprints.setUp/cancel, add blueprints.dialogDesc
across en/ja/zh/zh-hant + types.
- Dropped the stray \u2014 literal comment.
Logic (origin->local deliver, desktopDeliverOptions, merge into $cronJobs) is
unchanged and stays unit-tested. typecheck + eslint + tests green.
* fix(desktop): dropdown no longer closes the cron dialog; unify deliver targets
Two cron-dialog bugs:
1. Dismissing any Select dropdown inside the cron editor dialog closed the whole
dialog. Radix portals Select/Popover content outside the dialog, so the
dismiss pointerdown reached the Dialog's DismissableLayer as an
outside-interaction. Guard DialogContent.onInteractOutside: swallow
interactions originating from a [data-radix-popper-content-wrapper] (a
dropdown dismiss inside our own dialog), compose with any caller handler. Fix
is at the shared Dialog level so every dialog benefits.
2. Blueprint deliver only offered 'This desktop'. The blueprint used the backend
blueprint field.options (configured gateways only) while the manual editor
hardcoded local/telegram/discord/slack/email regardless of what's connected.
Wire the desktop to GET /api/cron/delivery-targets (the documented single
source of truth, already used by the dashboard) via getCronDeliveryTargets,
and render both the manual editor and the blueprint deliver slot through one
shared DeliverSelect. Now all three surfaces agree and only offer connected
platforms; unconfigured-home-channel targets show a hint.
i18n: add cron.deliverNeedsHomeChannel across en/ja/zh/zh-hant + types.
typecheck + eslint + vitest green.
* fix(desktop): clicking away from an open dropdown no longer closes the dialog
The onInteractOutside guard only caught pointerdowns whose target was inside
the popper wrapper. But dismissing an open Select by clicking elsewhere inside
the dialog also closes the popover, which moves focus — and Radix Dialog reads
that as focusOutside and closes the whole dialog. (Radix Select 2.3.1 has no
modal prop, so that escape hatch isn't available.)
Guard both paths at the shared DialogContent level: onInteractOutside AND
onFocusOutside now swallow the event when it originates from a Radix popper OR
when any [data-radix-popper-content-wrapper] is open at event time (covers the
focus/re-dispatch case where the target is no longer the popper). A genuine
backdrop click with no dropdown open still closes the dialog. Export
isInteractionFromPopper + unit-test the three cases.
typecheck + eslint + vitest green (7 dialog tests).
* fix(desktop): portal popovers into their dialog so dropdowns don't close it
Root cause (affected every dialog, not just cron): Radix Select/Popover/
DropdownMenu portal to document.body — a SIBLING of the dialog, outside its DOM
subtree. Dismissing a dropdown (or clicking another field) moves focus out of
the dialog subtree, which the Dialog's modal FocusScope/DismissableLayer reads
as an outside interaction and closes the whole dialog. Separate body-level
portals also make z-index across the two fragile.
The earlier onInteractOutside/onFocusOutside guards treated symptoms and didn't
hold (and Radix Select 2.3.1 has no modal prop to disable its layer). Real fix
is a layering system: DialogContent publishes its content node via
DialogPortalContainerContext; SelectContent/PopoverContent/DropdownMenuContent
call usePopoverPortalContainer() and portal INTO that node when inside a dialog
(document.body otherwise). The popover is then a true DOM descendant of the
dialog — focus stays in, dismissal no longer closes the dialog, and both share
one stacking context so z-index is deterministic.
Test: with a Dialog open, an open Select's item is a descendant of the dialog
(portalled in), verified in jsdom. typecheck + eslint + component tests green.
* fix(desktop): bump radix-ui so dismissing a dropdown can't close its dialog
Upstream bug, not app-layer: with radix-ui 1.6.0 (dismissable-layer 1.1.13),
an open modal Select sets pointer-events: none on the dialog body, so a click
anywhere inside the dialog hit-tests through to the overlay. The Dialog's
DismissableLayer defers its outside-pointerdown decision to the click, but the
overlay is a registered dismissable surface exempt from the interception check
— so the Select swallowing the press didn't count, the deferred onDismiss
fired, and the dialog closed along with the dropdown.
dismissable-layer 1.1.17 adds shouldHandlePointerDownOutside, which makes the
dialog's layer ignore the press entirely while a higher layer has its pointer
events disabled. Bump radix-ui ^1.4.3 -> ^1.6.5 (dismissable-layer 1.1.17,
dialog 1.1.21, select 2.3.5); lockfile diff is Radix-only.
Repro test fires the pointerdown/up/click sequence on the overlay with a
Select open inside the dialog: red on 1.6.0 (dialog closed), green on 1.6.5.
Counterpart test keeps a genuine overlay click closing the dialog.
typecheck + dialog/select component tests green. Full-suite failures on this
Windows host reproduce identically without the bump (pre-existing env issues).
This commit is contained in:
parent
4a9447c726
commit
0600ac2f77
20 changed files with 1454 additions and 669 deletions
|
|
@ -111,7 +111,7 @@
|
|||
"motion": "^12.38.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"node-pty": "1.1.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"radix-ui": "^1.6.5",
|
||||
"react": "^19.2.5",
|
||||
"react-arborist": "^3.5.0",
|
||||
"react-dnd-html5-backend": "^14.0.3",
|
||||
|
|
|
|||
97
apps/desktop/src/app/cron/blueprints.test.ts
Normal file
97
apps/desktop/src/app/cron/blueprints.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AutomationBlueprint } from '@/hermes'
|
||||
|
||||
import { initialBlueprintValues } from './blueprints'
|
||||
|
||||
function blueprint(fields: AutomationBlueprint['fields']): AutomationBlueprint {
|
||||
return {
|
||||
key: 'test',
|
||||
title: 'Test',
|
||||
description: '',
|
||||
category: 'general',
|
||||
tags: [],
|
||||
command: '',
|
||||
appUrl: '',
|
||||
fields
|
||||
}
|
||||
}
|
||||
|
||||
describe('initialBlueprintValues', () => {
|
||||
it('seeds each field from its default', () => {
|
||||
const values = initialBlueprintValues(
|
||||
blueprint([
|
||||
{ name: 'time', type: 'time', label: 'Time', default: '08:00', options: [], optional: false, help: '' },
|
||||
{
|
||||
name: 'topic',
|
||||
type: 'enum',
|
||||
label: 'Topic',
|
||||
default: 'news',
|
||||
options: ['news', 'sports'],
|
||||
optional: false,
|
||||
help: ''
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(values).toEqual({ time: '08:00', topic: 'news' })
|
||||
})
|
||||
|
||||
it('falls back to an empty string when a field has no default', () => {
|
||||
const values = initialBlueprintValues(
|
||||
blueprint([{ name: 'topic', type: 'text', label: 'Topic', default: null, options: [], optional: true, help: '' }])
|
||||
)
|
||||
|
||||
expect(values).toEqual({ topic: '' })
|
||||
})
|
||||
|
||||
it('returns an empty object for a blueprint with no fields', () => {
|
||||
expect(initialBlueprintValues(blueprint([]))).toEqual({})
|
||||
})
|
||||
|
||||
it("seeds the deliver slot to 'local' when its default is the dashboard-only 'origin'", () => {
|
||||
const values = initialBlueprintValues(
|
||||
blueprint([
|
||||
{
|
||||
name: 'deliver',
|
||||
type: 'enum',
|
||||
label: 'Deliver',
|
||||
default: 'origin',
|
||||
options: ['origin', 'local', 'telegram'],
|
||||
optional: false,
|
||||
help: ''
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(values).toEqual({ deliver: 'local' })
|
||||
})
|
||||
|
||||
it("seeds the deliver slot to 'local' when it has no default", () => {
|
||||
const values = initialBlueprintValues(
|
||||
blueprint([
|
||||
{ name: 'deliver', type: 'enum', label: 'Deliver', default: null, options: ['origin', 'local'], optional: false, help: '' }
|
||||
])
|
||||
)
|
||||
|
||||
expect(values).toEqual({ deliver: 'local' })
|
||||
})
|
||||
|
||||
it('leaves a non-origin deliver default untouched', () => {
|
||||
const values = initialBlueprintValues(
|
||||
blueprint([
|
||||
{
|
||||
name: 'deliver',
|
||||
type: 'enum',
|
||||
label: 'Deliver',
|
||||
default: 'telegram',
|
||||
options: ['origin', 'local', 'telegram'],
|
||||
optional: false,
|
||||
help: ''
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(values).toEqual({ deliver: 'telegram' })
|
||||
})
|
||||
})
|
||||
160
apps/desktop/src/app/cron/blueprints.tsx
Normal file
160
apps/desktop/src/app/cron/blueprints.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
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
|
||||
// created from). Desktop has no origin chat, so seed the deliver slot to the
|
||||
// desktop's native target ("local" = This desktop) instead. The dialog then
|
||||
// renders that slot with the shared DeliverSelect (backend-sourced targets), so
|
||||
// the raw "origin" option never reaches the desktop UI.
|
||||
const DELIVER_FIELD = 'deliver'
|
||||
const DESKTOP_DELIVER_DEFAULT = 'local'
|
||||
|
||||
function isDeliverField(field: AutomationBlueprintField): boolean {
|
||||
return field.name === DELIVER_FIELD
|
||||
}
|
||||
|
||||
// Initial form state for a blueprint = each field's default (or ''). Pure so the
|
||||
// suite can assert the form seeds correctly without mounting React. The deliver
|
||||
// slot is special-cased: an "origin" default (or empty) becomes "local" so a
|
||||
// desktop-created job delivers to This desktop instead of nowhere.
|
||||
export function initialBlueprintValues(blueprint: AutomationBlueprint): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
|
||||
for (const field of blueprint.fields) {
|
||||
const seeded = field.default ?? ''
|
||||
out[field.name] = isDeliverField(field) && (seeded === '' || seeded === 'origin') ? DESKTOP_DELIVER_DEFAULT : seeded
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// A slot-level validation error from the backend arrives as "422: <message>"
|
||||
// (or "<code>: <message>"); strip the leading numeric code for inline display.
|
||||
export function cleanBlueprintFieldError(message: string): string {
|
||||
return message.replace(/^\d+:\s*/, '')
|
||||
}
|
||||
|
||||
// Help text to show under a slot control. The backend deliver help is
|
||||
// origin/dashboard-centric and even contradicts desktop semantics ("local =
|
||||
// save only" vs. This desktop), and the DeliverSelect is self-explanatory —
|
||||
// skip it for the deliver slot.
|
||||
export function blueprintSlotHelp(field: AutomationBlueprintField): string | undefined {
|
||||
return field.help && field.type !== 'text' && !isDeliverField(field) ? field.help : undefined
|
||||
}
|
||||
|
||||
// Renders one blueprint slot's control (enum/weekdays → Select, time → time
|
||||
// input, else text). The deliver slot is handled separately by the dialog's
|
||||
// shared DeliverSelect, so it's not rendered here.
|
||||
export function BlueprintSlotControl({
|
||||
field,
|
||||
id,
|
||||
onChange,
|
||||
value
|
||||
}: {
|
||||
field: AutomationBlueprintField
|
||||
id: string
|
||||
onChange: (next: string) => void
|
||||
value: string
|
||||
}) {
|
||||
if (field.type === 'enum' || field.type === 'weekdays') {
|
||||
return (
|
||||
<Select onValueChange={onChange} value={value}>
|
||||
<SelectTrigger className="h-9 rounded-md" id={id}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map(option => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'time') {
|
||||
return <Input id={id} onChange={event => onChange(event.target.value)} type="time" value={value} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.help || field.label}
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 } from '@/components/ui/codicon'
|
||||
import { Codicon, codiconIcon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -26,11 +27,15 @@ import {
|
|||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
type AutomationBlueprint,
|
||||
createCronJob,
|
||||
type CronDeliveryTarget,
|
||||
type CronJob,
|
||||
deleteCronJob,
|
||||
getCronDeliveryTargets,
|
||||
getCronJobRuns,
|
||||
getCronJobs,
|
||||
instantiateAutomationBlueprint,
|
||||
pauseCronJob,
|
||||
resumeCronJob,
|
||||
type SessionInfo,
|
||||
|
|
@ -65,17 +70,27 @@ import {
|
|||
} from '../overlays/panel'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import {
|
||||
BlueprintSlotControl,
|
||||
blueprintSlotHelp,
|
||||
BlueprintsPanel,
|
||||
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__'
|
||||
|
||||
const DELIVERY_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email']
|
||||
|
||||
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
|
||||
{ expr: '0 9 * * *', value: 'daily' },
|
||||
{ expr: '0 9 * * 1-5', value: 'weekdays' },
|
||||
|
|
@ -290,6 +305,7 @@ 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)
|
||||
|
|
@ -437,9 +453,53 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
setEditor({ mode: 'closed' })
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const job = await instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile)
|
||||
|
||||
updateCronJobs(rows => {
|
||||
const rest = rows.filter(row => row.id !== job.id)
|
||||
|
||||
return [...rest, job]
|
||||
})
|
||||
notify({ kind: 'success', title: c.blueprints.scheduled, message: asText(job.schedule_display) || blueprint.title })
|
||||
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}>
|
||||
{loading && jobs.length === 0 ? (
|
||||
<PanelHeader actions={tabToggle} subtitle={tab === 'jobs' ? c.count(totalCount) : c.blueprints.subtitle} 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 ? (
|
||||
<PageLoader label={c.loading} />
|
||||
) : totalCount === 0 ? (
|
||||
<PanelEmpty
|
||||
|
|
@ -453,59 +513,61 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
title={c.emptyTitleNew}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<PanelHeader subtitle={c.count(totalCount)} title={c.title} />
|
||||
<PanelBody>
|
||||
<PanelList
|
||||
onSearchChange={setQuery}
|
||||
searchHints={jobs
|
||||
.map(jobTitle)
|
||||
.filter(Boolean)
|
||||
.slice(0, 5)
|
||||
.map(title => t.common.tryHint(title))}
|
||||
searchLabel={c.search}
|
||||
searchPlaceholder={c.search}
|
||||
searchValue={query}
|
||||
>
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobListRow
|
||||
active={selectedJob?.id === job.id}
|
||||
job={job}
|
||||
key={job.id}
|
||||
menu={
|
||||
<PanelRowMenu
|
||||
items={[
|
||||
{ icon: 'edit', label: c.edit, onSelect: () => setEditor({ mode: 'edit', job }) },
|
||||
{ icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
onSelect={() => setSelectedJobId(job.id)}
|
||||
/>
|
||||
))}
|
||||
{visibleJobs.length === 0 && (
|
||||
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{c.emptyTitleSearch}</p>
|
||||
)}
|
||||
<PanelAddButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
|
||||
</PanelList>
|
||||
|
||||
{selectedJob ? (
|
||||
<CronJobDetail
|
||||
busy={busyJobId === selectedJob.id}
|
||||
c={c}
|
||||
job={selectedJob}
|
||||
onOpenSession={onOpenSession}
|
||||
onPauseResume={() => void handlePauseResume(selectedJob)}
|
||||
onTrigger={() => void handleTrigger(selectedJob)}
|
||||
<PanelBody>
|
||||
<PanelList
|
||||
onSearchChange={setQuery}
|
||||
searchHints={jobs
|
||||
.map(jobTitle)
|
||||
.filter(Boolean)
|
||||
.slice(0, 5)
|
||||
.map(title => t.common.tryHint(title))}
|
||||
searchLabel={c.search}
|
||||
searchPlaceholder={c.search}
|
||||
searchValue={query}
|
||||
>
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobListRow
|
||||
active={selectedJob?.id === job.id}
|
||||
job={job}
|
||||
key={job.id}
|
||||
menu={
|
||||
<PanelRowMenu
|
||||
items={[
|
||||
{ icon: 'edit', label: c.edit, onSelect: () => setEditor({ mode: 'edit', job }) },
|
||||
{ icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
onSelect={() => setSelectedJobId(job.id)}
|
||||
/>
|
||||
) : (
|
||||
<PanelEmpty description={c.emptyDescSearch} icon="search" />
|
||||
))}
|
||||
{visibleJobs.length === 0 && (
|
||||
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{c.emptyTitleSearch}</p>
|
||||
)}
|
||||
</PanelBody>
|
||||
</>
|
||||
<PanelAddButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
|
||||
</PanelList>
|
||||
|
||||
{selectedJob ? (
|
||||
<CronJobDetail
|
||||
busy={busyJobId === selectedJob.id}
|
||||
c={c}
|
||||
job={selectedJob}
|
||||
onOpenSession={onOpenSession}
|
||||
onPauseResume={() => void handlePauseResume(selectedJob)}
|
||||
onTrigger={() => void handleTrigger(selectedJob)}
|
||||
/>
|
||||
) : (
|
||||
<PanelEmpty description={c.emptyDescSearch} icon="search" />
|
||||
)}
|
||||
</PanelBody>
|
||||
)}
|
||||
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
<CronEditorDialog
|
||||
editor={editor}
|
||||
onBlueprintCreate={handleBlueprintCreate}
|
||||
onClose={() => setEditor({ mode: 'closed' })}
|
||||
onSave={handleEditorSave}
|
||||
/>
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<DialogContent className="max-w-md">
|
||||
|
|
@ -727,12 +789,57 @@ function CronJobRuns({
|
|||
)
|
||||
}
|
||||
|
||||
// Label a cron delivery target: 'local' → localized "This desktop", known
|
||||
// platforms → their delivery label, anything else → the backend name. Configured
|
||||
// platforms without a cron home channel get a "set a home channel first" hint.
|
||||
function deliverTargetLabel(target: CronDeliveryTarget, c: Translations['cron']): string {
|
||||
const base = target.id === 'local' ? c.deliveryLabels.local : (c.deliveryLabels[target.id] ?? target.name)
|
||||
|
||||
return target.id !== 'local' && !target.home_target_set ? `${base} — ${c.deliverNeedsHomeChannel}` : base
|
||||
}
|
||||
|
||||
// The delivery-target dropdown, shared by the manual cron editor and the
|
||||
// blueprint form so both offer exactly the connected platforms (never a
|
||||
// hardcoded list). While the targets load, keep the current value selectable.
|
||||
function DeliverSelect({
|
||||
c,
|
||||
id,
|
||||
onChange,
|
||||
targets,
|
||||
value
|
||||
}: {
|
||||
c: Translations['cron']
|
||||
id: string
|
||||
onChange: (next: string) => void
|
||||
targets: CronDeliveryTarget[]
|
||||
value: string
|
||||
}) {
|
||||
const options = targets.length > 0 ? targets : [{ home_env_var: null, home_target_set: true, id: value, name: value }]
|
||||
|
||||
return (
|
||||
<Select onValueChange={onChange} value={value}>
|
||||
<SelectTrigger className="h-9 rounded-md" id={id}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(target => (
|
||||
<SelectItem key={target.id} value={target.id}>
|
||||
{deliverTargetLabel(target, c)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
function CronEditorDialog({
|
||||
editor,
|
||||
onBlueprintCreate,
|
||||
onClose,
|
||||
onSave
|
||||
}: {
|
||||
editor: EditorState
|
||||
onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record<string, string>, profile: string) => Promise<void>
|
||||
onClose: () => void
|
||||
onSave: (values: EditorValues) => Promise<void>
|
||||
}) {
|
||||
|
|
@ -740,6 +847,8 @@ 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
|
||||
|
||||
|
|
@ -751,16 +860,28 @@ 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.
|
||||
const [slotValues, setSlotValues] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
// Same catalog the chat model picker uses: configured providers and their
|
||||
// actually-available models only. Script-only jobs never run an agent, so
|
||||
// skip the fetch entirely for them.
|
||||
// actually-available models only. Script-only + blueprint dialogs never pick a
|
||||
// model here, so skip the fetch entirely for them.
|
||||
const modelOptions = useQuery({
|
||||
queryKey: ['model-options', 'global'],
|
||||
queryFn: () => requestModelOptions({}),
|
||||
enabled: open && !scriptOnlyJob
|
||||
enabled: open && !scriptOnlyJob && !isBlueprint
|
||||
})
|
||||
|
||||
// Single source of truth for where a cron can deliver (local + configured
|
||||
// gateways) — same endpoint the dashboard uses, so no dialog offers a platform
|
||||
// that isn't connected. Shared by the manual editor and the blueprint form.
|
||||
const deliveryTargets = useQuery({
|
||||
queryKey: ['cron-delivery-targets'],
|
||||
queryFn: getCronDeliveryTargets,
|
||||
enabled: open
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -774,9 +895,10 @@ 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) : {})
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
}, [initial, open])
|
||||
}, [blueprint, initial, open])
|
||||
|
||||
const selectedScheduleOption =
|
||||
SCHEDULE_OPTIONS.find(candidate => candidate.value === schedulePreset) ?? SCHEDULE_OPTIONS[0]
|
||||
|
|
@ -855,15 +977,86 @@ function CronEditorDialog({
|
|||
}
|
||||
}
|
||||
|
||||
async function handleBlueprintSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!isBlueprint) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onBlueprintCreate(editor.blueprint, slotValues, editor.profile)
|
||||
} catch (err) {
|
||||
// 422 carries the slot-level validation message; surface it inline.
|
||||
setError(cleanBlueprintFieldError(err instanceof Error ? err.message : String(err)))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
|
||||
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
|
||||
<DialogTitle>{isBlueprint ? blueprint?.title : isEdit ? c.editTitle : c.createTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isBlueprint ? blueprint?.description || c.blueprints.dialogDesc : isEdit ? c.editDesc : c.createDesc}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
{isBlueprint && blueprint ? (
|
||||
<form className="grid gap-4" onSubmit={handleBlueprintSubmit}>
|
||||
{blueprint.fields.map(field => {
|
||||
const fieldId = `blueprint-${blueprint.key}-${field.name}`
|
||||
const help = blueprintSlotHelp(field)
|
||||
|
||||
return (
|
||||
<Field htmlFor={fieldId} key={field.name} label={field.label}>
|
||||
{field.name === 'deliver' ? (
|
||||
// Use the shared, backend-sourced delivery targets (same as the
|
||||
// manual editor) rather than the blueprint's static field.options,
|
||||
// so both dialogs offer exactly the connected platforms.
|
||||
<DeliverSelect
|
||||
c={c}
|
||||
id={fieldId}
|
||||
onChange={next => setSlotValues(prev => ({ ...prev, [field.name]: next }))}
|
||||
targets={deliveryTargets.data ?? []}
|
||||
value={slotValues[field.name] ?? DEFAULT_DELIVER}
|
||||
/>
|
||||
) : (
|
||||
<BlueprintSlotControl
|
||||
field={field}
|
||||
id={fieldId}
|
||||
onChange={next => setSlotValues(prev => ({ ...prev, [field.name]: next }))}
|
||||
value={slotValues[field.name] ?? ''}
|
||||
/>
|
||||
)}
|
||||
{help && <FieldHint>{help}</FieldHint>}
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={saving} type="submit">
|
||||
{saving ? c.blueprints.scheduling : c.blueprints.scheduleIt}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : (
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
{scriptOnlyJob && initial && (
|
||||
<FieldHint>
|
||||
{c.scriptOnlyEditHint} <span className="font-mono">{initial.id}</span>
|
||||
|
|
@ -907,18 +1100,13 @@ function CronEditorDialog({
|
|||
</Field>
|
||||
|
||||
<Field htmlFor="cron-deliver" label={c.deliverLabel}>
|
||||
<Select onValueChange={setDeliver} value={deliver}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_VALUES.map(value => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{c.deliveryLabels[value]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DeliverSelect
|
||||
c={c}
|
||||
id="cron-deliver"
|
||||
onChange={setDeliver}
|
||||
targets={deliveryTargets.data ?? []}
|
||||
value={deliver}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
|
|
@ -990,6 +1178,7 @@ function CronEditorDialog({
|
|||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
|
@ -1023,7 +1212,11 @@ function FieldHint({ children }: { children: React.ReactNode }) {
|
|||
return <p className="text-[0.66rem] leading-4 text-muted-foreground">{children}</p>
|
||||
}
|
||||
|
||||
type EditorState = { mode: 'closed' } | { mode: 'create' } | { job: CronJob; mode: 'edit' }
|
||||
type EditorState =
|
||||
| { blueprint: AutomationBlueprint; mode: 'blueprint'; profile: string }
|
||||
| { job: CronJob; mode: 'edit' }
|
||||
| { mode: 'closed' }
|
||||
| { mode: 'create' }
|
||||
|
||||
interface EditorValues {
|
||||
deliver: string
|
||||
|
|
|
|||
|
|
@ -71,7 +71,11 @@ interface PanelHeaderProps {
|
|||
|
||||
export function PanelHeader({ actions, subtitle, title }: PanelHeaderProps) {
|
||||
return (
|
||||
<header className="mb-3 flex shrink-0 items-start justify-between gap-3">
|
||||
// The overlay's close (X) is absolutely positioned at right-3 and costs no
|
||||
// layout space, so header actions would otherwise slide right up against it.
|
||||
// Reserve clearance (button footprint from the card edge + a small gap) on
|
||||
// the right whenever actions are present.
|
||||
<header className={cn('mb-3 flex shrink-0 items-start justify-between gap-3', actions ? 'pr-8' : undefined)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||
{subtitle ? <p className="truncate text-xs text-muted-foreground/80">{subtitle}</p> : null}
|
||||
|
|
|
|||
87
apps/desktop/src/components/ui/dialog-dismiss-repro.test.tsx
Normal file
87
apps/desktop/src/components/ui/dialog-dismiss-repro.test.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle } from './dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// jsdom lacks these; Radix Select calls them on open.
|
||||
window.HTMLElement.prototype.scrollIntoView = vi.fn()
|
||||
window.HTMLElement.prototype.hasPointerCapture = vi.fn()
|
||||
window.HTMLElement.prototype.releasePointerCapture = vi.fn()
|
||||
|
||||
const flushTimers = () => new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
describe('REPRO: dismissing an open Select inside a Dialog', () => {
|
||||
it('closes only the select, not the dialog, when the press lands on the overlay', async () => {
|
||||
// While a modal Select is open, the dialog content div is pointer-events:
|
||||
// none, so a real click anywhere in the dialog body hit-tests through to
|
||||
// the overlay. Simulate that by firing the pointer sequence on the overlay.
|
||||
const onOpenChange = vi.fn()
|
||||
render(
|
||||
<Dialog onOpenChange={onOpenChange} open>
|
||||
<DialogContent>
|
||||
<DialogTitle>Test dialog</DialogTitle>
|
||||
<Select defaultOpen>
|
||||
<SelectTrigger aria-label="picker">
|
||||
<SelectValue placeholder="pick" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="a">Option A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
// DismissableLayer registers its document pointerdown listener in a
|
||||
// setTimeout(0) — flush it first.
|
||||
await flushTimers()
|
||||
|
||||
const overlay = document.querySelector('[data-slot="dialog-overlay"]') as HTMLElement
|
||||
expect(overlay).toBeTruthy()
|
||||
expect(screen.getByText('Option A')).toBeTruthy()
|
||||
|
||||
fireEvent.pointerDown(overlay, { button: 0 })
|
||||
fireEvent.pointerUp(overlay, { button: 0 })
|
||||
fireEvent.click(overlay, { button: 0 })
|
||||
|
||||
// The deferred dismissal path resolves through another setTimeout(0).
|
||||
await flushTimers()
|
||||
|
||||
// The select closed...
|
||||
expect(screen.queryByText('Option A')).toBeNull()
|
||||
// ...but the dialog must NOT have been asked to close.
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('still closes the dialog on a genuine overlay click when no dropdown is open', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(
|
||||
<Dialog onOpenChange={onOpenChange} open>
|
||||
<DialogContent>
|
||||
<DialogTitle>Test dialog</DialogTitle>
|
||||
<Select>
|
||||
<SelectTrigger aria-label="picker">
|
||||
<SelectValue placeholder="pick" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="a">Option A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
await flushTimers()
|
||||
|
||||
const overlay = document.querySelector('[data-slot="dialog-overlay"]') as HTMLElement
|
||||
fireEvent.pointerDown(overlay, { button: 0 })
|
||||
fireEvent.pointerUp(overlay, { button: 0 })
|
||||
fireEvent.click(overlay, { button: 0 })
|
||||
await flushTimers()
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
29
apps/desktop/src/components/ui/dialog-portal-context.ts
Normal file
29
apps/desktop/src/components/ui/dialog-portal-context.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import * as React from 'react'
|
||||
|
||||
// Layering system for portalled popovers (Select / Popover / DropdownMenu) that
|
||||
// open INSIDE a Dialog.
|
||||
//
|
||||
// By default every Radix popover portals to `document.body`, i.e. as a sibling
|
||||
// of the Dialog — OUTSIDE the dialog's DOM subtree. That breaks two things:
|
||||
// 1. Dismissing the popover (clicking elsewhere in the dialog) moves focus and
|
||||
// fires pointer/focus events the Dialog's DismissableLayer reads as
|
||||
// "outside", so the whole dialog closes.
|
||||
// 2. z-index across separate body-level portals is fragile — the popover and
|
||||
// dialog live in different stacking siblings.
|
||||
//
|
||||
// The fix: a Dialog publishes its own content node here; any popover rendered
|
||||
// inside that Dialog portals into that node instead of `document.body`. Now the
|
||||
// popover is a real DOM descendant of the dialog — focus never leaves, the
|
||||
// DismissableLayer sees it as inside, and it shares the dialog's stacking
|
||||
// context so z-index is deterministic. Outside a dialog the value is null and
|
||||
// popovers fall back to the default body portal.
|
||||
export const DialogPortalContainerContext = React.createContext<HTMLElement | null>(null)
|
||||
|
||||
// The container a popover should portal into: an explicit `container` prop wins,
|
||||
// then the enclosing dialog's content node, then undefined (Radix default:
|
||||
// document.body).
|
||||
export function usePopoverPortalContainer(explicit?: HTMLElement | null): HTMLElement | undefined {
|
||||
const fromDialog = React.useContext(DialogPortalContainerContext)
|
||||
|
||||
return explicit ?? fromDialog ?? undefined
|
||||
}
|
||||
|
|
@ -2,9 +2,40 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle, preventCloseButtonAutoFocus } from './dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Select portalled inside a Dialog', () => {
|
||||
it('renders the open dropdown inside the dialog DOM subtree, not document.body', () => {
|
||||
// Force the Select open (defaultOpen) to sidestep jsdom's missing
|
||||
// hasPointerCapture on the trigger. The dropdown content must land inside the
|
||||
// dialog content node — that DOM nesting is what keeps focus in the dialog so
|
||||
// dismissing the dropdown no longer closes the dialog.
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<DialogTitle>Test dialog</DialogTitle>
|
||||
<Select defaultOpen>
|
||||
<SelectTrigger aria-label="picker">
|
||||
<SelectValue placeholder="pick" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="a">Option A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const option = screen.getByText('Option A')
|
||||
|
||||
// The dropdown item is a descendant of the dialog, i.e. portalled into it.
|
||||
expect(dialog.contains(option)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DialogContent close button', () => {
|
||||
it('closes the dialog when clicked', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
|
|||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogPortalContainerContext } from '@/components/ui/dialog-portal-context'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { X } from '@/lib/icons'
|
||||
|
|
@ -85,6 +86,15 @@ function DialogContent({
|
|||
|
||||
const widthClass = fitContent ? 'w-auto max-w-[92vw]' : 'w-full max-w-lg'
|
||||
|
||||
// Publish the dialog's content node so popovers (Select / Popover /
|
||||
// DropdownMenu) opened inside it portal INTO the dialog instead of
|
||||
// document.body. That keeps them as DOM descendants — focus never leaves the
|
||||
// dialog, so dismissing a dropdown (or clicking another field) no longer
|
||||
// trips the Dialog's outside-interaction/focus-out close. See
|
||||
// dialog-portal-context.ts. State (not just a ref) so consumers re-render once
|
||||
// the node mounts.
|
||||
const [contentNode, setContentNode] = React.useState<HTMLElement | null>(null)
|
||||
|
||||
// No default here — Radix's normal autofocus (first focusable element, often
|
||||
// an input) is what most dialogs want. Dialogs with no input should pass
|
||||
// `onOpenAutoFocus={preventCloseButtonAutoFocus}` explicitly instead.
|
||||
|
|
@ -129,25 +139,28 @@ function DialogContent({
|
|||
)}
|
||||
data-slot="dialog-content"
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
ref={setContentNode}
|
||||
{...props}
|
||||
>
|
||||
{/* Scroll lives on an inner box so this shell keeps a painted bottom radius. */}
|
||||
<div className="relative z-10 overflow-hidden rounded-xl border border-b-0 border-(--stroke-nous) bg-(--ui-chat-bubble-background)">
|
||||
<div className="grid max-h-[calc(85vh-5rem)] min-h-0 gap-3 overflow-y-auto p-4">{children}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
// Overlap by one corner radius so the white bottom lobes read clearly
|
||||
// over the tint instead of meeting it on a straight seam.
|
||||
'relative z-0 -mt-[var(--radius-xl)] px-4 pb-2.5 pt-[calc(var(--radius-xl)+0.625rem)] text-center text-[length:var(--conversation-tool-font-size)] leading-relaxed shadow-[inset_0_7px_7px_-4px_rgb(0_0_0/0.28)]',
|
||||
DIALOG_BANNER_TONES[bannerTone]
|
||||
)}
|
||||
data-slot="dialog-banner"
|
||||
role={bannerTone === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
{banner}
|
||||
</div>
|
||||
{closeButton}
|
||||
<DialogPortalContainerContext.Provider value={contentNode}>
|
||||
{/* Scroll lives on an inner box so this shell keeps a painted bottom radius. */}
|
||||
<div className="relative z-10 overflow-hidden rounded-xl border border-b-0 border-(--stroke-nous) bg-(--ui-chat-bubble-background)">
|
||||
<div className="grid max-h-[calc(85vh-5rem)] min-h-0 gap-3 overflow-y-auto p-4">{children}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
// Overlap by one corner radius so the white bottom lobes read clearly
|
||||
// over the tint instead of meeting it on a straight seam.
|
||||
'relative z-0 -mt-[var(--radius-xl)] px-4 pb-2.5 pt-[calc(var(--radius-xl)+0.625rem)] text-center text-[length:var(--conversation-tool-font-size)] leading-relaxed shadow-[inset_0_7px_7px_-4px_rgb(0_0_0/0.28)]',
|
||||
DIALOG_BANNER_TONES[bannerTone]
|
||||
)}
|
||||
data-slot="dialog-banner"
|
||||
role={bannerTone === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
{banner}
|
||||
</div>
|
||||
{closeButton}
|
||||
</DialogPortalContainerContext.Provider>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
|
|
@ -167,10 +180,13 @@ function DialogContent({
|
|||
)}
|
||||
data-slot="dialog-content"
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
ref={setContentNode}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{closeButton}
|
||||
<DialogPortalContainerContext.Provider value={contentNode}>
|
||||
{children}
|
||||
{closeButton}
|
||||
</DialogPortalContainerContext.Provider>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
|
|||
import * as React from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { usePopoverPortalContainer } from '@/components/ui/dialog-portal-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared class tokens for edge-to-edge menus (use with `p-0` content): rows go
|
||||
|
|
@ -73,8 +74,12 @@ function DropdownMenuContent({
|
|||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
// Portal into the enclosing dialog when nested in one, so dismissing the menu
|
||||
// doesn't close the dialog; document.body otherwise.
|
||||
const container = usePopoverPortalContainer()
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Portal container={container}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
// `dt-portal-scrollbar` reproduces the thin themed scrollbar from
|
||||
// `.scrollbar-dt` for portaled overlays (Radix renders this under
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Popover as PopoverPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { usePopoverPortalContainer } from '@/components/ui/dialog-portal-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
|
|
@ -27,8 +28,12 @@ function PopoverContent({
|
|||
sideOffset = 6,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
// Portal into the enclosing dialog when nested in one (keeps focus inside so
|
||||
// the dialog doesn't close on dismiss); document.body otherwise.
|
||||
const container = usePopoverPortalContainer()
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Portal container={container}>
|
||||
<PopoverPrimitive.Content
|
||||
align={align}
|
||||
arrowPadding={arrowPadding}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import * as React from 'react'
|
|||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { type ControlVariantProps, controlVariants } from '@/components/ui/control'
|
||||
import { usePopoverPortalContainer } from '@/components/ui/dialog-portal-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
|
|
@ -43,8 +44,13 @@ function SelectContent({
|
|||
position = 'popper',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
// Portal into the enclosing dialog (if any) so the dropdown is a DOM
|
||||
// descendant of the dialog — keeps focus inside and stops the dialog closing
|
||||
// when the dropdown is dismissed. Falls back to document.body outside a dialog.
|
||||
const container = usePopoverPortalContainer()
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Portal container={container}>
|
||||
<SelectPrimitive.Content
|
||||
className={cn(
|
||||
'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',
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ import type {
|
|||
AnalyticsResponse,
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
AutomationBlueprint,
|
||||
AuxiliaryModelsResponse,
|
||||
BackendUpdateCheckResponse,
|
||||
ComputerUseStatus,
|
||||
ConfigSchemaResponse,
|
||||
CronDeliveryTarget,
|
||||
CronJob,
|
||||
CronJobCreatePayload,
|
||||
CronJobUpdates,
|
||||
|
|
@ -130,6 +132,8 @@ export type {
|
|||
AnalyticsTotals,
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
AutomationBlueprint,
|
||||
AutomationBlueprintField,
|
||||
AuxiliaryModelsResponse,
|
||||
BackendUpdateCheckResponse,
|
||||
ComputerUseCheck,
|
||||
|
|
@ -137,6 +141,7 @@ export type {
|
|||
ComputerUseStatus,
|
||||
ConfigFieldSchema,
|
||||
ConfigSchemaResponse,
|
||||
CronDeliveryTarget,
|
||||
CronJob,
|
||||
CronJobCreatePayload,
|
||||
CronJobSchedule,
|
||||
|
|
@ -1211,6 +1216,18 @@ export async function getCronJobRuns(jobId: string, limit = 20): Promise<Session
|
|||
return runs ?? []
|
||||
}
|
||||
|
||||
// The single source of truth for cron delivery targets (local + configured
|
||||
// gateways). Both the manual cron editor and the blueprint dialog use this so
|
||||
// they never offer a platform that isn't connected. Mirrors the dashboard.
|
||||
export async function getCronDeliveryTargets(): Promise<CronDeliveryTarget[]> {
|
||||
const { targets } = await window.hermesDesktop.api<{ targets: CronDeliveryTarget[] }>({
|
||||
...profileScoped(),
|
||||
path: '/api/cron/delivery-targets'
|
||||
})
|
||||
|
||||
return targets ?? []
|
||||
}
|
||||
|
||||
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
...profileScoped(),
|
||||
|
|
@ -1261,6 +1278,37 @@ export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> {
|
|||
})
|
||||
}
|
||||
|
||||
// Automation Blueprints — parameterized cron templates the backend serves from
|
||||
// cron/blueprint_catalog.py. getAutomationBlueprints returns the gallery
|
||||
// (deliver options already rewritten to this machine's configured gateways);
|
||||
// instantiateAutomationBlueprint fills the slots and creates a real cron job via
|
||||
// the same create_job path as createCronJob.
|
||||
//
|
||||
// Profile-scoping is intentionally asymmetric: the GET catalog is global (the
|
||||
// list endpoint takes no profile — only deliver options are rewritten from the
|
||||
// configured gateways), so it carries only the profileScoped() header for
|
||||
// routing. instantiate creates a real per-profile job, so it names the target
|
||||
// profile explicitly via ?profile=. This mirrors the dashboard's api.ts.
|
||||
export function getAutomationBlueprints(): Promise<{ blueprints: AutomationBlueprint[] }> {
|
||||
return window.hermesDesktop.api<{ blueprints: AutomationBlueprint[] }>({
|
||||
...profileScoped(),
|
||||
path: '/api/cron/blueprints',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function instantiateAutomationBlueprint(
|
||||
body: { blueprint: string; values: Record<string, string> },
|
||||
profile: string
|
||||
): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
...profileScoped(),
|
||||
path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`,
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function getProfiles(): Promise<ProfilesResponse> {
|
||||
return window.hermesDesktop.api<ProfilesResponse>({
|
||||
path: '/api/profiles',
|
||||
|
|
|
|||
|
|
@ -1669,6 +1669,7 @@ export const en: Translations = {
|
|||
promptPlaceholder: 'Summarize my unread Slack threads and email me the top 5...',
|
||||
frequencyLabel: 'Frequency',
|
||||
deliverLabel: 'Deliver to',
|
||||
deliverNeedsHomeChannel: 'set a home channel first',
|
||||
modelLabel: 'Model',
|
||||
modelDefault: 'Default (global model)',
|
||||
customScheduleLabel: 'Custom schedule',
|
||||
|
|
@ -1680,7 +1681,23 @@ export const en: Translations = {
|
|||
scheduleRequired: 'Schedule is required.',
|
||||
scriptOnlyEditHint: 'Script-only job (no AI prompt). Job id:',
|
||||
saveChanges: 'Save changes',
|
||||
createAction: 'Create cron'
|
||||
createAction: 'Create cron',
|
||||
tabs: {
|
||||
jobs: 'Jobs',
|
||||
blueprints: 'Blueprints'
|
||||
},
|
||||
blueprints: {
|
||||
tab: 'Blueprints',
|
||||
subtitle: 'Ready-made automations',
|
||||
dialogDesc: 'Fill in the details and schedule it.',
|
||||
scheduleIt: 'Schedule it',
|
||||
scheduling: 'Scheduling...',
|
||||
scheduled: 'Blueprint scheduled',
|
||||
loading: 'Loading blueprints...',
|
||||
failedLoad: 'Failed to load blueprints',
|
||||
emptyTitle: 'No blueprints available',
|
||||
emptyDesc: 'No automation blueprints are available on this backend.'
|
||||
}
|
||||
},
|
||||
|
||||
artifacts: {
|
||||
|
|
|
|||
|
|
@ -1541,6 +1541,7 @@ export const ja = defineLocale({
|
|||
promptPlaceholder: '実行ごとにエージェントが行う内容は?',
|
||||
frequencyLabel: '頻度',
|
||||
deliverLabel: '配信先',
|
||||
deliverNeedsHomeChannel: '先にホームチャンネルを設定してください',
|
||||
modelLabel: 'モデル',
|
||||
modelDefault: 'デフォルト(グローバルモデル)',
|
||||
customScheduleLabel: 'カスタムスケジュール',
|
||||
|
|
@ -1552,7 +1553,23 @@ export const ja = defineLocale({
|
|||
scheduleRequired: 'スケジュールは必須です。',
|
||||
scriptOnlyEditHint: 'スクリプトのみのジョブ(AI プロンプトなし)。ジョブ ID:',
|
||||
saveChanges: '変更を保存',
|
||||
createAction: 'Cron を作成'
|
||||
createAction: 'Cron を作成',
|
||||
tabs: {
|
||||
jobs: 'ジョブ',
|
||||
blueprints: 'ブレーンプリント'
|
||||
},
|
||||
blueprints: {
|
||||
tab: 'ブレーンプリント',
|
||||
subtitle: 'すぐに使える自動化',
|
||||
dialogDesc: '詳細を入力してスケジュールします。',
|
||||
scheduleIt: 'スケジュールする',
|
||||
scheduling: 'スケジュール中...',
|
||||
scheduled: 'ブレーンプリントをスケジュールしました',
|
||||
loading: 'ブレーンプリントを読み込み中...',
|
||||
failedLoad: 'ブレーンプリントの読み込みに失敗しました',
|
||||
emptyTitle: '利用できるブレーンプリントはありません',
|
||||
emptyDesc: 'このバックエンドで利用できる自動化ブレーンプリントはありません。'
|
||||
}
|
||||
},
|
||||
|
||||
artifacts: {
|
||||
|
|
|
|||
|
|
@ -1380,6 +1380,7 @@ export interface Translations {
|
|||
promptPlaceholder: string
|
||||
frequencyLabel: string
|
||||
deliverLabel: string
|
||||
deliverNeedsHomeChannel: string
|
||||
modelLabel: string
|
||||
modelDefault: string
|
||||
customScheduleLabel: string
|
||||
|
|
@ -1392,6 +1393,22 @@ export interface Translations {
|
|||
scriptOnlyEditHint: string
|
||||
saveChanges: string
|
||||
createAction: string
|
||||
tabs: {
|
||||
jobs: string
|
||||
blueprints: string
|
||||
}
|
||||
blueprints: {
|
||||
tab: string
|
||||
subtitle: string
|
||||
dialogDesc: string
|
||||
scheduleIt: string
|
||||
scheduling: string
|
||||
scheduled: string
|
||||
loading: string
|
||||
failedLoad: string
|
||||
emptyTitle: string
|
||||
emptyDesc: string
|
||||
}
|
||||
}
|
||||
|
||||
artifacts: {
|
||||
|
|
|
|||
|
|
@ -1490,6 +1490,7 @@ export const zhHant = defineLocale({
|
|||
promptPlaceholder: '代理每次執行時應做什麼?',
|
||||
frequencyLabel: '頻率',
|
||||
deliverLabel: '傳遞至',
|
||||
deliverNeedsHomeChannel: '請先設定主頻道',
|
||||
modelLabel: '模型',
|
||||
modelDefault: '預設(全域模型)',
|
||||
customScheduleLabel: '自訂排程',
|
||||
|
|
@ -1501,7 +1502,23 @@ export const zhHant = defineLocale({
|
|||
scheduleRequired: '排程為必填項目。',
|
||||
scriptOnlyEditHint: '僅腳本任務(無 AI 提示詞)。任務 ID:',
|
||||
saveChanges: '儲存變更',
|
||||
createAction: '建立排程工作'
|
||||
createAction: '建立排程工作',
|
||||
tabs: {
|
||||
jobs: '工作',
|
||||
blueprints: '藍圖'
|
||||
},
|
||||
blueprints: {
|
||||
tab: '藍圖',
|
||||
subtitle: '現成的自動化',
|
||||
dialogDesc: '填寫詳細資訊並進行排程。',
|
||||
scheduleIt: '安排工作',
|
||||
scheduling: '安排中...',
|
||||
scheduled: '藍圖已安排',
|
||||
loading: '正在載入藍圖...',
|
||||
failedLoad: '載入藍圖失敗',
|
||||
emptyTitle: '沒有可用的藍圖',
|
||||
emptyDesc: '此後端上沒有可用的自動化藍圖。'
|
||||
}
|
||||
},
|
||||
|
||||
artifacts: {
|
||||
|
|
|
|||
|
|
@ -1860,6 +1860,7 @@ export const zh: Translations = {
|
|||
promptPlaceholder: '总结我未读的 Slack 话题,并把前 5 条邮件发给我…',
|
||||
frequencyLabel: '频率',
|
||||
deliverLabel: '投递至',
|
||||
deliverNeedsHomeChannel: '请先设置主频道',
|
||||
modelLabel: '模型',
|
||||
modelDefault: '默认(全局模型)',
|
||||
customScheduleLabel: '自定义排程',
|
||||
|
|
@ -1871,7 +1872,23 @@ export const zh: Translations = {
|
|||
scheduleRequired: '排程为必填项。',
|
||||
scriptOnlyEditHint: '仅脚本任务(无 AI 提示词)。任务 ID:',
|
||||
saveChanges: '保存更改',
|
||||
createAction: '创建定时任务'
|
||||
createAction: '创建定时任务',
|
||||
tabs: {
|
||||
jobs: '任务',
|
||||
blueprints: '蓝图'
|
||||
},
|
||||
blueprints: {
|
||||
tab: '蓝图',
|
||||
subtitle: '现成的自动化',
|
||||
dialogDesc: '填写详细信息并进行排程。',
|
||||
scheduleIt: '安排任务',
|
||||
scheduling: '安排中...',
|
||||
scheduled: '蓝图已安排',
|
||||
loading: '正在加载蓝图...',
|
||||
failedLoad: '加载蓝图失败',
|
||||
emptyTitle: '没有可用的蓝图',
|
||||
emptyDesc: '此后端上没有可用的自动化蓝图。'
|
||||
}
|
||||
},
|
||||
|
||||
artifacts: {
|
||||
|
|
|
|||
|
|
@ -728,6 +728,46 @@ export interface CronJobUpdates {
|
|||
schedule?: string
|
||||
}
|
||||
|
||||
// A cron delivery target from GET /api/cron/delivery-targets — the single
|
||||
// source of truth (cron.scheduler.cron_delivery_targets) for where a cron job
|
||||
// can auto-deliver. Only 'local' plus configured gateway platforms appear; a
|
||||
// configured platform without a cron home channel comes back with
|
||||
// home_target_set=false so the UI can flag it.
|
||||
export interface CronDeliveryTarget {
|
||||
home_env_var: null | string
|
||||
home_target_set: boolean
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// Automation Blueprints — parameterized cron templates with typed slots. The
|
||||
// backend (cron/blueprint_catalog.py) is the single source of truth; the
|
||||
// desktop renders each slot as a form field, then instantiates a real cron job
|
||||
// via the same create_job path as everything else. Shapes mirror the JSON from
|
||||
// GET /api/cron/blueprints (blueprint_catalog_entry).
|
||||
export interface AutomationBlueprintField {
|
||||
name: string
|
||||
type: 'enum' | 'text' | 'time' | 'weekdays'
|
||||
label: string
|
||||
default: null | string
|
||||
options: string[]
|
||||
optional: boolean
|
||||
/** When false, options are suggestions — any value is accepted. */
|
||||
strict?: boolean
|
||||
help: string
|
||||
}
|
||||
|
||||
export interface AutomationBlueprint {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
tags: string[]
|
||||
fields: AutomationBlueprintField[]
|
||||
command: string
|
||||
appUrl: string
|
||||
}
|
||||
|
||||
export interface ProfileCreatePayload {
|
||||
clone_all?: boolean
|
||||
clone_from?: null | string
|
||||
|
|
|
|||
1115
package-lock.json
generated
1115
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue