Repository navigation
feat(desktop): add Cron Blueprints to the GUI - #70066
Conversation
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.
૮ >ﻌ< ა ci reviewran on 530ac4a ℹ️ InfoDesktop E2E visual evidence · View test artifacts · View job1 visual diff. inline evidence upload failed. Failed to upload diff-665a0833239e-onboarding-overlay-diff.png with gh image (exit code 1): Error uploading /home/runner/work/_temp/e2e-evidence/diff-665a0833239e-onboarding-overlay-diff.png: step 0 (get upload token): uploadToken not found on repo page — do you have write access to NousResearch/hermes-agent? (or, if NousResearch enforces SAML SSO, authorize at https://github.com/orgs/NousResearch/sso) |
… 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.
…al 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).
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.
OutThisLife
left a comment
There was a problem hiding this comment.
Approve. Verified against the backend: getAutomationBlueprints/instantiateAutomationBlueprint hit real routes (GET /api/cron/blueprints, POST /api/cron/blueprints/instantiate?profile=), the AutomationBlueprint/Field types mirror blueprint_catalog_entry, and instantiate creates a real job via the same create_job path. Real parity gap with the dashboard's AutomationBlueprints.tsx.
The origin→local deliver remap (the shared catalog defaults to a dashboard-only origin target desktop lacks) is the right call and is unit-tested; the created job is merged into the shared $cronJobs atom so Jobs/sidebar update immediately. Full i18n + types.
Advances #67144.
|
Code's approved — one UI consistency pass before it lands. Each of these pages currently draws its items its own way (the cron Jobs tab, this Blueprints tab, and the Webhooks PR each differ), and we already have the item pattern set elsewhere. This PR is much closer than the webhooks one — it already uses
The |
OutThisLife
left a comment
There was a problem hiding this comment.
Logic's good, requesting changes on the UI layer to match the neighboring surfaces — details in my comment above: the bespoke BlueprintCard container (a third item shape inside the same Panel) and the raw <label>+<Input> rows vs. the shared ListRow field primitive.
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.
|
Both addressed in 81e8d0f.
No behavior change. typecheck clean, blueprint tests green. |
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.
OutThisLife
left a comment
There was a problem hiding this comment.
Re-reviewed — both addressed. The blueprint slot form now uses the shared ListRow primitive (label + help left, control right) so it matches the settings/messaging forms, and the card sits in the Panel family via the shared bg-(--ui-bg-quinary) grouping token instead of a bespoke card look. PanelPill/PanelEmpty/PageLoader/useQuery were already right, and the origin→local deliver handling + merge-into-$cronJobs are intact. CI green. Good to go.
OutThisLife
left a comment
There was a problem hiding this comment.
Ran the merged build with seeded data and looked at the Blueprints tab directly — the form fields moved to ListRow, but the card + form flow still doesn't match the app. A few things:
-
Card should be the shared card primitive. The card hand-rolls
rounded-md bg-(--ui-bg-quinary) p-3— no border, no hover, its own radius/padding. Every other card surface in the app (theme picker, pet picker, gateway/profile cards, marketplace, pet-generate) usesselectableCardClass()fromlib/selectable-card.ts, which isrounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover). That mismatch (borderlessrounded-mdvs borderedrounded-lg) is why the padding/weight reads differently from the rest of the UI. UseselectableCardClass({ prominent: true })and let the caller own padding to match the neighbors (p-2). -
Drop the "Set up" accordion and the divider — put the form in the dialog this panel already uses. The expand-in-place form (with the
border-t border-border/60divider) isn't a pattern used anywhere else. This exact panel already has the canonical "fill parameters → create a cron job" component:CronEditorDialog(opened by manual New cron), and the sibling Webhooks PR puts its create form in aDialogtoo. A blueprint card should just open that dialog pre-filled with the blueprint's slots on click. That removes the "Set up" toggle, removes the inline divider, and makes blueprint creation look/behave like every other create form in the app. -
PanelBodymisuse (minor).PanelBodyis the master/detail flex-row container; the blueprints tab puts a single column in it and then re-declares its own scroller (flex flex-col gap-2 overflow-y-auto pr-1). Render the gallery column directly (or viaPanelDetail) rather than nesting a scroller inside the master/detail row.
The logic (origin→local deliver, merge into $cronJobs, react-query) is all fine — this is the presentation layer still going its own way on the card + form.
There was a problem hiding this comment.
Fuller pass on apps/desktop/src/app/cron/blueprints.tsx against the cron page's own patterns and apps/desktop/DESIGN.md. Logic (origin->local, desktopDeliverOptions, merge into $cronJobs, react-query) is fine — this is the card/form presentation still diverging.
-
Card isn't the shared card primitive.
BlueprintCarduses<div className="rounded-md bg-(--ui-bg-quinary) p-3">— no border,rounded-md, its own padding. Every card surface (theme/pet/gateway/profile/marketplace) usesselectableCardClass()fromlib/selectable-card.ts(rounded-lg border border-(--ui-stroke-tertiary) ... hover:bg-(--chrome-action-hover)). DESIGN.md Principle 5: "Variants and sizes own padding, radius, color, chrome. Call sites pass a variant/size, not className overrides that re-specify those." -
Drop the "Set up"/"Cancel" accordion — use the dialog this panel already has. The canonical "fill parameters -> create a cron job" form is
CronEditorDialog(opened by manual New cron): aDialog(max-w-lg) withField/FieldHint,grid gap-4, and aDialogFooter. A blueprint card should open that same dialog pre-filled with the blueprint's slots on click, not expand an inline form behind a toggle. -
Remove the
border-t border-border/60divider. This is a direct DESIGN.md violation twice over:- Principle 1 (Flat, not boxed): "No divider borders inside a panel. Group with whitespace and a single hairline." The rows section repeats it: "No dividers between rows unless the list genuinely needs them; prefer spacing." The
mt-3gap already separates the form. - Principle 4 (Tokens, not literals): even where an in-panel hairline is warranted, DESIGN.md names
--ui-stroke-tertiaryas "the default in-panel divider / list hairline."border-border/60is a raw opacity literal; the checklist wants "zero raw colors."
- Principle 1 (Flat, not boxed): "No divider borders inside a panel. Group with whitespace and a single hairline." The rows section repeats it: "No dividers between rows unless the list genuinely needs them; prefer spacing." The
-
Use
Field/FieldHint, notListRow, for the slots. The peer cron editor lays fields out withField(label above the control,grid gap-1.5). The blueprint form usesListRow(label left / control right) with a<label>smuggled into thetitle. In the dialog, match the editor'sField. -
Buttons are ad hoc.
variant={open ? 'ghost' : 'outline'}toggling plus a bare<Button type="submit">. The editor usesDialogFooterwith anoutlineCancel + default submit. Once this is a dialog, adopt that footer. -
Error rendering diverges. Blueprint shows a plain
<p className="text-xs text-destructive">. The editor renders errors in the shared block (flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive+AlertTriangle). Reuse that. -
PanelBodymisuse. Inindex.tsxthe blueprints tab renders<PanelBody><BlueprintsPanel/></PanelBody>, andBlueprintsPanelreturns its ownflex ... overflow-y-auto pr-1.PanelBodyis the master/detail flex-row (gap-4/gap-5, flex-row when wide); a single gallery column nested in it inherits row layout it doesn't use, and re-inventsPanelDetail's scroller with different padding (pr-1vspl-1 pr-2,gap-2vsspace-y-4). Render the gallery as a plain scroll region (orPanelDetail) rather than insidePanelBody. -
Nit: line ~235 has a literal
\u2014inside a//comment (renders as backslash-u-2014, not an em-dash). Use a real character.
Net: dropping the divider and moving the form into the cron editor dialog resolves 2, 3, 4, 5, and 6 at once.
Superseded by the consolidated review below.
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.
|
Reworked in 62ab52e. The blueprint UI now reuses the app's canonical pieces instead of a bespoke surface. Key move: generalized Point by point:
i18n: dropped the now-unused |
…r 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.
…e 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).
…ose 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.
…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).
OutThisLife
left a comment
There was a problem hiding this comment.
Re-reviewed at 530ac4a with the same magnifying glass — all eight points are addressed, and cleanly:
- Card is now a
selectableCardClass({ prominent: true })button (no bespokerounded-mddiv, no override chrome). - The inline "Set up" accordion and the
border-tdivider are gone; a card click opens the sharedCronEditorDialogvia the newmode: 'blueprint'. - Slots render with
Field/FieldHint, errors use the sharedrounded-md bg-destructive/10 ... AlertTriangleblock, and the footer is the standardDialogFooter(outline Cancel + submit) — identical to the manual editor. PanelBodymisuse fixed (BlueprintsPanelreturnsPanelDetail, rendered directly).- The
\u2014comment literal is a real em-dash now.
Nice extras beyond the review: the shared DeliverSelect (backend-sourced targets for both dialogs) and the generic Select-in-Dialog dismissal fix (DialogPortalContainerContext + usePopoverPortalContainer, with repro + dialog tests) — that's the correct app-wide fix, not a one-off.
LGTM.
* 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).
* 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).
* 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).
What
Adds Cron Blueprints to the desktop 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). This closes that gap.
Changes
BlueprintsPanel(apps/desktop/src/app/cron/blueprints.tsx): fetches the catalog from the existingGET /api/cron/blueprints, one card per template with an inline 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 samecreate_jobpath as a hand-written one. The created job is merged into the shared$cronJobsatom so the Jobs tab and sidebar reflect it immediately.getAutomationBlueprints()/instantiateAutomationBlueprint()inhermes.ts(profile-scoped, matching the sibling cron endpoints) plus theAutomationBlueprint/AutomationBlueprintFieldtypes.cron.tabs.*andcron.blueprints.*added to the canonicalTranslationsinterface (i18n/types.ts) and all four locales (en/ja/zh/zh-hant).initialBlueprintValueshelper.Backend already served both endpoints; this is desktop frontend only. No backend or schema changes.
Testing
tsc -p . --noEmitis clean for all changed files locally. Fulllint+vitestto be run in CI / on a machine with a native-matching install (the WSL /mnt/c dev environment can't complete a usablenpm install).