From 67927808bebb2e21cc17896bf0baf488e68b5e89 Mon Sep 17 00:00:00 2001 From: Carbon <127478722+carbongotfound@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:27:04 +0300 Subject: [PATCH] feat(desktop): support multiple cron delivery targets --- .../src/app/cron/cron-job-model.test.ts | 30 ++++++++- apps/desktop/src/app/cron/cron-job-model.ts | 23 +++++++ apps/desktop/src/app/cron/index.tsx | 61 +++++++++++++------ 3 files changed, 93 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/app/cron/cron-job-model.test.ts b/apps/desktop/src/app/cron/cron-job-model.test.ts index a506f418fd..8c45f66bdd 100644 --- a/apps/desktop/src/app/cron/cron-job-model.test.ts +++ b/apps/desktop/src/app/cron/cron-job-model.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' -import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model' +import { + cronEditorUpdates, + jobIsScriptOnly, + parseCronDeliveryTargets, + toggleCronDeliveryTarget, + validateCronEditor +} from './cron-job-model' describe('jobIsScriptOnly', () => { it('is true when no_agent is set and a script is present', () => { @@ -31,6 +37,28 @@ describe('validateCronEditor', () => { }) }) +describe('cron delivery targets', () => { + it('parses comma-separated targets and removes duplicates', () => { + expect(parseCronDeliveryTargets('local, telegram,local')).toEqual(['local', 'telegram']) + }) + + it('falls back to local for an empty stored value', () => { + expect(parseCronDeliveryTargets('')).toEqual(['local']) + }) + + it('adds a second target in the scheduler comma-separated format', () => { + expect(toggleCronDeliveryTarget('local', 'origin', true)).toBe('local,origin') + }) + + it('removes one target while keeping the other selection', () => { + expect(toggleCronDeliveryTarget('local,origin', 'local', false)).toBe('origin') + }) + + it('does not allow the final delivery target to be unchecked', () => { + expect(toggleCronDeliveryTarget('origin', 'origin', false)).toBe('origin') + }) +}) + describe('cronEditorUpdates', () => { it('omits prompt when saving a script-only job with an empty prompt', () => { expect( diff --git a/apps/desktop/src/app/cron/cron-job-model.ts b/apps/desktop/src/app/cron/cron-job-model.ts index bd7c5de5ba..9b800aea70 100644 --- a/apps/desktop/src/app/cron/cron-job-model.ts +++ b/apps/desktop/src/app/cron/cron-job-model.ts @@ -45,6 +45,29 @@ export interface CronEditorSaveValues { schedule: string } +export function parseCronDeliveryTargets(value: string): string[] { + const targets = value + .split(',') + .map(target => target.trim()) + .filter(Boolean) + + return targets.length > 0 ? [...new Set(targets)] : ['local'] +} + +export function toggleCronDeliveryTarget(value: string, target: string, checked: boolean): string { + const targets = parseCronDeliveryTargets(value) + + if (checked) { + return targets.includes(target) ? targets.join(',') : [...targets, target].join(',') + } + + if (!targets.includes(target) || targets.length === 1) { + return targets.join(',') + } + + return targets.filter(candidate => candidate !== target).join(',') +} + /** Build the API update payload, preserving an empty prompt on script-only jobs. */ export function cronEditorUpdates(values: CronEditorSaveValues, options: { scriptOnlyJob: boolean }): CronJobUpdates { const updates: CronJobUpdates = { diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 0a0eb418d2..a42aa7d814 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' import { Codicon } from '@/components/ui/codicon' import { Dialog, @@ -73,7 +74,13 @@ import { import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import { BlueprintSlotControl, blueprintSlotHelp, cleanBlueprintFieldError, initialBlueprintValues } from './blueprints' -import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model' +import { + cronEditorUpdates, + jobIsScriptOnly, + parseCronDeliveryTargets, + toggleCronDeliveryTarget, + validateCronEditor +} from './cron-job-model' import { jobState, jobTitle, STATE_DOT } from './job-state' const DEFAULT_DELIVER = 'local' @@ -778,10 +785,11 @@ function deliverTargetLabel(target: CronDeliveryTarget, c: Translations['cron']) 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({ +// The delivery-target checkbox group, shared by the manual cron editor and the +// blueprint form. The scheduler accepts comma-separated targets, so users can +// keep results local while also sending them to connected platforms. Preserve +// selected targets missing from discovery so editing never drops a saved route. +function DeliverCheckboxes({ c, id, onChange, @@ -794,21 +802,34 @@ function DeliverSelect({ targets: CronDeliveryTarget[] value: string }) { - const options = targets.length > 0 ? targets : [{ home_env_var: null, home_target_set: true, id: value, name: value }] + const selected = parseCronDeliveryTargets(value) + const knownIds = new Set(targets.map(target => target.id)) + + const options = [ + ...targets, + ...selected + .filter(target => !knownIds.has(target)) + .map(target => ({ home_env_var: null, home_target_set: true, id: target, name: target })) + ] return ( - +