@goodandready/dsh-cron 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -15,6 +15,9 @@ window.__ModuleLoader__.load({
15
15
  const ACTIVATE_EVENT = 'dsh-panel-activate';
16
16
  const ACTIVE_ATTR = 'data-dsh-cron-active';
17
17
  const ENTRY_ATTR = 'data-dsh-cron-entry';
18
+ // Set by the sidebar job list (#34) so the panel can highlight the task the
19
+ // user clicked; cleared once the panel has scrolled to it.
20
+ let pendingTaskHighlight = null;
18
21
  const VIEW_ATTR = 'data-dsh-cron-view';
19
22
  const COLUMN_SELECTOR = '[data-pane=conversation], [class*=centerCol]';
20
23
  const SESSION_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="newSession"]';
@@ -32,6 +35,9 @@ window.__ModuleLoader__.load({
32
35
  const ICON_ACTIVITY = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>';
33
36
  const ICON_EDIT = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>';
34
37
  const ICON_SEND = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>';
38
+ const ICON_COPY = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
39
+ const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
40
+ const ICON_UPLOAD = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>';
35
41
  const ICON_TELEGRAM = '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8-1.7 8.01c-.13.58-.47.72-.95.45l-2.6-1.92-1.25 1.21c-.14.14-.26.26-.53.26l.19-2.64 4.81-4.35c.21-.19-.05-.29-.32-.11L8.34 13.5 5.78 12.7c-.56-.17-.57-.56.12-.83l10-3.86c.46-.17.87.11.74.79z"/></svg>';
36
42
 
37
43
  // Locale dictionary: English is the canonical source language (Issue #87).
@@ -40,6 +46,9 @@ window.__ModuleLoader__.load({
40
46
  const STRINGS = {
41
47
  en: {
42
48
  'sidebar.label': 'Scheduled tasks',
49
+ 'sidebar.jobsTitle': 'Active jobs',
50
+ 'sidebar.noActiveJobs': 'No active jobs',
51
+ 'sidebar.moreJobs': 'and {count} more',
43
52
  'chip.title': 'Scheduled tasks (Cron)',
44
53
  'panel.back': 'Back to chat',
45
54
  'panel.title': 'Scheduled tasks',
@@ -50,11 +59,37 @@ window.__ModuleLoader__.load({
50
59
  'actions.runNow': 'Run now',
51
60
  'actions.runNowShort': 'Run',
52
61
  'actions.edit': 'Edit / Details',
62
+ 'actions.duplicate': 'Duplicate task',
53
63
  'actions.delete': 'Delete task',
64
+ 'duplicate.done': 'Copy created and paused: review its schedule before resuming it.',
65
+ 'duplicate.oneShotHint': 'The copy is one-shot and paused — its original time has likely passed, so set a new time before resuming.',
66
+ 'duplicate.failed': 'Could not duplicate the task: {error}',
67
+ 'transfer.exportTitle': 'Export tasks',
68
+ 'transfer.importTitle': 'Import tasks',
69
+ 'transfer.exported': 'Exported {count} task(s).',
70
+ 'transfer.exportFailed': 'Export failed: {error}',
71
+ 'transfer.importTitleModal': 'Import tasks',
72
+ 'transfer.importHint': 'Exported task configuration only: no run history, no counters. Secrets are not included — channels reference credentials by name, so make sure those credential names exist on the target harness.',
73
+ 'transfer.strategyAdd': 'Add as new tasks (new ids, existing ones stay untouched)',
74
+ 'transfer.strategyReplace': 'Replace tasks with the same id',
75
+ 'transfer.strategySkip': 'Skip tasks whose id already exists',
76
+ 'transfer.summary': 'This file contains {total} task(s): {add} new, {replace} matching an existing id, {skip} already skipped.',
77
+ 'transfer.confirm': 'Import',
78
+ 'transfer.imported': 'Imported {count} task(s).',
79
+ 'transfer.importFailed': 'Import failed: {error}',
80
+ 'transfer.badFile': 'The selected file is not a valid JSON export: {error}',
54
81
  'actions.pause': 'Pause',
55
82
  'actions.resume': 'Resume',
56
- 'actions.settings': 'Telegram & Notifications',
83
+ 'actions.settings': 'Notifications & channels',
57
84
  'search.placeholder': 'Search scheduled tasks',
85
+ 'filters.title': 'Filters',
86
+ 'filters.type': 'Type',
87
+ 'filters.model': 'Model',
88
+ 'filters.channel': 'Channel',
89
+ 'filters.any': 'Any',
90
+ 'filters.reset': 'Reset filters',
91
+ 'filters.empty': 'No task matches the current filters.',
92
+ 'filters.showing': 'Showing {shown} of {total}',
58
93
  'stats.activeTasks': 'Active tasks',
59
94
  'stats.totalRuns': 'Total runs',
60
95
  'stats.totalTokens': 'Tokens spent',
@@ -94,7 +129,32 @@ window.__ModuleLoader__.load({
94
129
  'form.namePlaceholder': 'For example: dsh-tags-watch',
95
130
  'form.typeLabel': 'Execution type',
96
131
  'form.typeLlm': 'LLM (AI agent)',
97
- 'form.typeScript': 'NO-LLM (Shell command/script)',
132
+ 'form.typeScript': 'Shell command / script',
133
+ 'form.typeNode': 'Node.js',
134
+ 'form.typePython': 'Python',
135
+ 'form.typeHttp': 'HTTP request / webhook',
136
+ 'form.typeSsh': 'Remote SSH',
137
+ 'form.typeDocker': 'Docker container',
138
+ 'form.typeSkill': 'DSH Skill',
139
+ 'form.typeWorkflow': 'DSH Workflow',
140
+ 'form.envLabel': 'Environment variables (KEY=VALUE per line)',
141
+ 'form.envPlaceholder': 'NODE_ENV=production\nTZ=Europe/Berlin',
142
+ 'form.envHint': 'Applied to non-LLM runtimes only. Do not store secrets here.',
143
+ 'form.cwdLabel': 'Working directory (optional)',
144
+ 'form.workspaceLabel': 'Workspace id or path (optional)',
145
+ 'form.worktreeLabel': 'Run in an isolated git worktree',
146
+ 'form.keepWorktreeLabel': 'Keep the worktree after the run',
147
+ 'form.httpMethodLabel': 'HTTP method',
148
+ 'form.httpUrlLabel': 'URL',
149
+ 'form.httpHeadersLabel': 'Headers (JSON, optional)',
150
+ 'form.httpBodyLabel': 'Body (optional)',
151
+ 'form.sshProfileLabel': 'dsh-remote-workspace profile id',
152
+ 'form.sshProfileHint': 'Preferred: host and credentials stay in the remote-workspace plugin.',
153
+ 'form.sshTargetLabel': 'Fallback target (user@host, optional)',
154
+ 'form.sshKeyLabel': 'Private key path (fallback, optional)',
155
+ 'form.dockerImageLabel': 'Container image',
156
+ 'form.skillNameLabel': 'Skill name',
157
+ 'form.workflowNameLabel': 'Workflow name',
98
158
  'form.scheduleLabel': 'Schedule (cron or interval)',
99
159
  'form.schedulePlaceholder': '0 */6 * * * or every 2h or in 30m or at: 2026-09-05T15:00:00Z',
100
160
  'form.timeoutLabel': 'Execution timeout (seconds)',
@@ -131,15 +191,54 @@ window.__ModuleLoader__.load({
131
191
  'form.notifyTitle': 'Telegram notifications',
132
192
  'form.notifyTelegram': 'Send the run report to Telegram',
133
193
  'form.notifyOnlyFailure': 'Only on failures (onlyOnFailure / silent on success)',
194
+ 'form.channelsTitle': 'Delivery channels',
195
+ 'form.channelsHint': 'Pick the channels for this task. An explicit selection overrides the Telegram switch above; leave everything unchecked to fall back to it.',
196
+ 'form.templateLabel': 'Message template (optional)',
197
+ 'form.templatePlaceholder': '⏰ {title} — {status} in {duration}',
198
+ 'form.templateHint': 'Variables: {title} {id} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}. Empty = built-in text.',
199
+ 'form.channelTelegram': 'Telegram',
200
+ 'form.channelKanban': 'dsh-kanban card',
201
+ 'form.channelDiscord': 'Discord',
202
+ 'form.channelSlack': 'Slack',
203
+ 'form.channelNtfy': 'ntfy',
204
+ 'form.channelBark': 'Bark',
205
+ 'form.channelPushplus': 'PushPlus',
206
+ 'form.channelTts': 'Voice (dsh-tts)',
207
+ 'form.channelGitea': 'Gitea issue',
208
+ 'settings.deliverySection': 'Delivery channels',
209
+ 'settings.secretsSection': 'Credentials (references)',
210
+ 'settings.secretsHint': 'Enter the NAME of a DSH credential, not the secret itself. Secrets stay in the credentials store.',
211
+ 'settings.telegramSection': 'Telegram',
212
+ 'settings.templatesSection': 'Message templates',
213
+ 'settings.channelTemplateLabel': 'Template for {channel}',
214
+ 'settings.botTokenRefLabel': 'Bot token credential name',
215
+ 'settings.botTokenRefPlaceholder': 'e.g. TELEGRAM_BOT_TOKEN',
216
+ 'settings.discordLabel': 'Discord webhook URL',
217
+ 'settings.slackLabel': 'Slack webhook URL',
218
+ 'settings.ntfyUrlLabel': 'ntfy server URL',
219
+ 'settings.ntfyTopicLabel': 'ntfy topic',
220
+ 'settings.ntfyTokenRefLabel': 'ntfy token credential name',
221
+ 'settings.barkServerLabel': 'Bark server URL',
222
+ 'settings.barkKeyLabel': 'Bark device key',
223
+ 'settings.pushplusTokenRefLabel': 'PushPlus token credential name',
224
+ 'settings.pushplusUrlLabel': 'PushPlus endpoint',
225
+ 'settings.ttsBaseUrlLabel': 'dsh-tts base URL',
226
+ 'settings.giteaBaseUrlLabel': 'Gitea base URL',
227
+ 'settings.giteaRepoLabel': 'Gitea repository (owner/repo)',
228
+ 'settings.giteaTokenRefLabel': 'Gitea token credential name',
229
+ 'settings.templateLabel': 'Default message template',
230
+ 'settings.templateHint': 'Applies to every channel without its own template. Empty = built-in text.',
231
+ 'settings.deliveryTimeoutMsLabel': 'Delivery timeout per channel (ms)',
232
+ 'settings.secretsOnCard': 'Credentials referenced from the settings panel are shown masked and stored by name.',
134
233
  'form.nextRunLabel': 'Next run: ',
135
234
  'form.notScheduled': 'not scheduled',
136
235
  'settings.loading': 'Loading settings...',
137
236
  'settings.unavailable': 'Settings are unavailable right now.',
138
237
  'settings.retry': 'Retry',
139
238
  'settings.cardTitle': '⏰ Task scheduler (Cron)',
140
- 'settings.cardDesc': 'Scheduled task execution (cron/intervals), background agent scenarios and Telegram/Kanban integration.',
239
+ 'settings.cardDesc': 'Scheduled task execution (cron/intervals), background agent scenarios, and delivery of run reports to Telegram, Discord, Slack, ntfy, Bark, PushPlus, voice and Gitea.',
141
240
  'settings.openPanel': 'Open task panel',
142
- 'settings.modalTitle': 'Telegram & notification settings',
241
+ 'settings.modalTitle': 'Notifications & delivery channels',
143
242
  'settings.tokenLabel': 'Telegram Bot Token',
144
243
  'settings.tokenPlaceholder': '123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ',
145
244
  'settings.tokenPlaceholderDefault': 'Using the bot configured in DSH settings (or enter your own)',
@@ -231,6 +330,156 @@ window.__ModuleLoader__.load({
231
330
  }
232
331
  const ErrorBoundary = createErrorBoundary();
233
332
 
333
+ const AGENT_FORM_TYPES = ['llm', 'skill', 'workflow'];
334
+ const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
335
+
336
+ // Delivery channels (#26) — must stay in sync with lib/channels.js.
337
+ const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
338
+ const CHANNEL_LABEL_KEYS = {
339
+ telegram: 'form.channelTelegram',
340
+ kanban: 'form.channelKanban',
341
+ discord: 'form.channelDiscord',
342
+ slack: 'form.channelSlack',
343
+ ntfy: 'form.channelNtfy',
344
+ bark: 'form.channelBark',
345
+ pushplus: 'form.channelPushplus',
346
+ tts: 'form.channelTts',
347
+ gitea: 'form.channelGitea',
348
+ };
349
+
350
+ // Non-secret delivery settings shown in the settings panel. Secrets are
351
+ // referenced by credential name only (#51) — never typed as values.
352
+ const DELIVERY_SETTING_FIELDS = [
353
+ { key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
354
+ { key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
355
+ { key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
356
+ { key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
357
+ { key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
358
+ { key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
359
+ { key: 'ntfyUrl', labelKey: 'settings.ntfyUrlLabel', group: 'channels' },
360
+ { key: 'ntfyTopic', labelKey: 'settings.ntfyTopicLabel', group: 'channels' },
361
+ { key: 'barkServerUrl', labelKey: 'settings.barkServerLabel', group: 'channels' },
362
+ { key: 'barkKey', labelKey: 'settings.barkKeyLabel', group: 'channels' },
363
+ { key: 'pushplusUrl', labelKey: 'settings.pushplusUrlLabel', group: 'channels' },
364
+ { key: 'ttsBaseUrl', labelKey: 'settings.ttsBaseUrlLabel', group: 'channels' },
365
+ { key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
366
+ { key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
367
+ { key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true, defaultValue: 15000, min: 1000 },
368
+ ];
369
+ const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
370
+ // The global template plus the per-channel override map (server model).
371
+ const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
372
+
373
+ /** Collect every delivery key the settings endpoints may return. */
374
+ function pickDeliverySettings(source) {
375
+ const out = {};
376
+ for (const key of DELIVERY_SETTING_KEYS.concat(TEMPLATE_SETTING_KEYS)) {
377
+ if (source && source[key] !== undefined && source[key] !== null) out[key] = source[key];
378
+ }
379
+ if (out.channelTemplates && typeof out.channelTemplates === 'object') {
380
+ out.channelTemplates = Object.assign({}, out.channelTemplates);
381
+ }
382
+ return out;
383
+ }
384
+
385
+ /**
386
+ * Shared delivery form used by both the task panel modal and the plugin
387
+ * settings card, so a new channel is added in exactly one place.
388
+ * Sections collapse via the canonical aria-expanded head contract.
389
+ */
390
+ function renderDeliverySettings({ T, values, onChange, onChannelTemplate, sections, onToggleSection }) {
391
+ const val = (key) => (values[key] === undefined || values[key] === null ? '' : String(values[key]));
392
+ const field = (f) => React.createElement('div', { className: 'dsh-cron-form-group', key: f.key, style: { marginBottom: '10px' } },
393
+ React.createElement('label', null, T(f.labelKey)),
394
+ React.createElement('input', f.numeric ? {
395
+ type: 'number',
396
+ min: f.min !== undefined ? f.min : 1000,
397
+ step: 1000,
398
+ value: val(f.key),
399
+ placeholder: String(f.defaultValue !== undefined ? f.defaultValue : 15000),
400
+ // #115: clearing the field must send the explicit default. Deleting the
401
+ // key would leave the previously stored value in place (the settings
402
+ // scope skips absent keys), so the field would silently keep 1 ms.
403
+ onChange: (e) => onChange(f.key, e.target.value === ''
404
+ ? (f.defaultValue !== undefined ? f.defaultValue : 15000)
405
+ : Number(e.target.value)),
406
+ } : {
407
+ type: 'text',
408
+ value: val(f.key),
409
+ placeholder: f.placeholderKey ? T(f.placeholderKey) : '',
410
+ onChange: (e) => onChange(f.key, e.target.value)
411
+ })
412
+ );
413
+ const section = (key, titleKey, children) => {
414
+ const open = Boolean(sections[key]);
415
+ return React.createElement('div', { className: 'dsh-cron-section', key },
416
+ React.createElement('button', {
417
+ type: 'button',
418
+ className: 'dsh-cron-section-head',
419
+ 'aria-expanded': open ? 'true' : 'false',
420
+ onClick: () => onToggleSection(key)
421
+ },
422
+ React.createElement('span', null, T(titleKey)),
423
+ React.createElement('span', { className: 'dsh-cron-section-chevron', 'data-open': open ? 'true' : 'false' }, '▾')
424
+ ),
425
+ open && React.createElement('div', { className: 'dsh-cron-section-body' }, children)
426
+ );
427
+ };
428
+
429
+ return [
430
+ section('secrets', 'settings.secretsSection', [
431
+ React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.secretsHint')),
432
+ ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'secrets').map(field),
433
+ ]),
434
+ section('channels', 'settings.deliverySection', [
435
+ React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
436
+ ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
437
+ ),
438
+ ]),
439
+ section('templates', 'settings.templatesSection', [
440
+ React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
441
+ React.createElement('label', null, T('settings.templateLabel')),
442
+ React.createElement('input', {
443
+ type: 'text',
444
+ value: val('template'),
445
+ onChange: (e) => onChange('template', e.target.value)
446
+ }),
447
+ React.createElement('div', { className: 'dsh-cron-hint' }, T('settings.templateHint'))
448
+ ),
449
+ React.createElement('div', { className: 'dsh-cron-form-row', key: 'perchannel' },
450
+ ...DELIVERY_CHANNELS.map((id) => React.createElement('div', { className: 'dsh-cron-form-group', key: id, style: { marginBottom: '10px' } },
451
+ React.createElement('label', null, T('settings.channelTemplateLabel', { channel: T(CHANNEL_LABEL_KEYS[id]) })),
452
+ React.createElement('input', {
453
+ type: 'text',
454
+ value: (values.channelTemplates && values.channelTemplates[id]) || '',
455
+ onChange: (e) => onChannelTemplate(id, e.target.value)
456
+ })
457
+ ))
458
+ ),
459
+ ]),
460
+ ];
461
+ }
462
+
463
+ /** Parse a KEY=VALUE textarea into the task env object (#38). */
464
+ function parseEnvText(text) {
465
+ const env = {};
466
+ for (const rawLine of String(text || '').split('\n')) {
467
+ const line = rawLine.trim();
468
+ if (!line || line.startsWith('#')) continue;
469
+ const idx = line.indexOf('=');
470
+ if (idx <= 0) continue;
471
+ const key = line.slice(0, idx).trim();
472
+ const value = line.slice(idx + 1).trim();
473
+ if (key) env[key] = value;
474
+ }
475
+ return Object.keys(env).length ? env : undefined;
476
+ }
477
+
478
+ function formatEnvText(env) {
479
+ if (!env || typeof env !== 'object') return '';
480
+ return Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n');
481
+ }
482
+
234
483
  // Status colors: core semantic state tokens first (same set dsh-clinebot
235
484
  // uses), plugin fallbacks as a single point of change (#105).
236
485
  const STYLES = `
@@ -288,6 +537,12 @@ window.__ModuleLoader__.load({
288
537
  .dsh-cron-search-input { width: 100%; height: 36px; background: var(--dsw-alias-bg-layer-2, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 0 12px 0 38px; color: var(--dsw-alias-label-primary, inherit); font-size: 13px; outline: none; box-sizing: border-box; }
289
538
  .dsh-cron-search-input:focus { outline: none; border-color: var(--dsh-cron-info); }
290
539
  .dsh-cron-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--dsw-alias-label-tertiary, #666); }
540
+ .dsh-cron-filters { margin-top: 8px; }
541
+ .dsh-cron-filter-toggle { background: none; border: 1px solid var(--dsw-alias-border-l2, #333); color: var(--dsw-alias-label-secondary, #aaa); border-radius: 8px; padding: 4px 10px; font-size: 12px; cursor: pointer; }
542
+ .dsh-cron-filter-toggle:hover { color: var(--dsw-alias-label-primary, #eee); border-color: var(--dsh-cron-info); }
543
+ .dsh-cron-filter-panel { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin-top: 10px; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l2, #2e2e2e); border-radius: 8px; background: var(--dsw-alias-bg-layer-3, rgba(255,255,255,0.02)); }
544
+ .dsh-cron-filter-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); }
545
+ .dsh-cron-filter-field select { background: var(--dsw-alias-bg-layer-2, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); color: var(--dsw-alias-label-primary, inherit); border-radius: 6px; padding: 5px 8px; font-size: 12.5px; }
291
546
  .dsh-cron-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
292
547
  .dsh-cron-tab { appearance: none; background: transparent; border: 1px solid var(--dsw-alias-border-l2, #383838); padding: 4px 12px; border-radius: 999px; color: var(--dsw-alias-label-secondary, #9ca3af); font-size: 12px; font-weight: 500; cursor: pointer; }
293
548
  .dsh-cron-tab[data-active="true"] { background: var(--dsw-alias-bg-layer-4, #2a2a2a); color: var(--dsw-alias-label-primary, #fff); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #383838)); }
@@ -316,6 +571,12 @@ window.__ModuleLoader__.load({
316
571
 
317
572
  .dsh-cron-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; }
318
573
  .dsh-cron-modal { background: var(--dsw-alias-bg-layer-2, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 16px; width: 100%; max-width: 540px; padding: 24px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); box-sizing: border-box; }
574
+ .dsh-cron-modal-scroll { max-width: 620px; max-height: 88vh; overflow-y: auto; }
575
+ .dsh-cron-section-head { display: flex; align-items: center; justify-content: space-between; width: 100%; background: none; border: none; padding: 0; margin-bottom: 10px; font: inherit; font-weight: 500; font-size: 12.5px; color: var(--dsw-alias-label-secondary, #aaa); cursor: pointer; }
576
+ .dsh-cron-section-head:hover { color: var(--dsw-alias-label-primary, #eee); }
577
+ .dsh-cron-section-chevron { transition: transform 0.15s ease; display: inline-block; }
578
+ .dsh-cron-section-chevron[data-open="false"] { transform: rotate(-90deg); }
579
+ .dsh-cron-section-body { padding-bottom: 4px; }
319
580
  .dsh-cron-modal-title { font-size: 18px; font-weight: 600; margin-bottom: 18px; }
320
581
  .dsh-cron-form-group { margin-bottom: 16px; }
321
582
  .dsh-cron-form-group label { display: block; font-size: 13px; color: var(--dsw-alias-label-secondary, #aaa); margin-bottom: 6px; }
@@ -373,12 +634,63 @@ window.__ModuleLoader__.load({
373
634
  .dsh-cron-checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--dsw-alias-label-secondary, #ccc); cursor: pointer; user-select: none; }
374
635
  .dsh-cron-checkbox-row input { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: var(--dsw-alias-label-primary, #ededed); }
375
636
  .dsh-cron-hint { font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); margin-top: 4px; }
637
+ .dsh-cron-channel-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px 12px; }
638
+ .dsh-cron-section { border-top: 1px solid var(--dsw-alias-border-l2, #2e2e2e); margin-top: 16px; padding-top: 12px; }
639
+ .dsh-cron-section-title { font-weight: 500; font-size: 12.5px; color: var(--dsw-alias-label-secondary, #aaa); margin-bottom: 10px; }
640
+ .dsh-cron-secret-row { display: flex; align-items: center; gap: 8px; }
641
+ .dsh-cron-secret-row input { flex: 1; }
642
+ .dsh-cron-badge-ref { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--dsw-alias-bg-layer-3, rgba(255,255,255,0.06)); color: var(--dsw-alias-label-tertiary, #888); white-space: nowrap; }
376
643
  .dsh-cron-status-banner { padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-top: 12px; }
377
644
  .dsh-cron-status-banner[data-type="success"] { background: var(--dsh-cron-success-bg); border: 1px solid var(--dsh-cron-success); color: var(--dsh-cron-success); }
378
645
  .dsh-cron-status-banner[data-type="error"] { background: var(--dsh-cron-danger-bg); border: 1px solid var(--dsh-cron-danger); color: var(--dsh-cron-danger); }
379
646
  .dsh-cron-status-banner[data-type="info"] { background: var(--dsh-cron-info-bg); border: 1px solid var(--dsh-cron-info); color: var(--dsh-cron-info); }
380
647
  @keyframes dsh-cron-pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); } 70% { box-shadow: 0 0 0 9px rgba(16, 185, 129, 0); } 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } }
381
648
  .dsh-cron-pulse { animation: dsh-cron-pulse-ring 1.6s infinite; border-color: var(--dsh-cron-success); color: var(--dsh-cron-success); }
649
+
650
+ /* #34: sidebar job list */
651
+ .dsh-cron-sidebar-jobs { display: flex; flex-direction: column; gap: 2px; margin: 2px 0 6px 0; }
652
+ .dsh-cron-sidebar-jobs-head { display: flex; align-items: center; justify-content: space-between; width: 100%; background: none; border: none; padding: 4px 8px; font: inherit; font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); cursor: pointer; border-radius: 6px; text-align: left; }
653
+ .dsh-cron-sidebar-jobs-head:hover { color: var(--dsw-alias-label-primary, #eee); background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.04)); }
654
+ .dsh-cron-sidebar-jobs-head::after { content: '▾'; font-size: 10px; transition: transform 0.15s ease; }
655
+ .dsh-cron-sidebar-jobs[data-open="false"] .dsh-cron-sidebar-jobs-head::after { transform: rotate(-90deg); }
656
+ .dsh-cron-sidebar-jobs[data-open="false"] .dsh-cron-sidebar-jobs-list { display: none; }
657
+ .dsh-cron-sidebar-jobs-list { display: flex; flex-direction: column; gap: 2px; padding-left: 10px; }
658
+ .dsh-cron-sidebar-job { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; width: 100%; background: none; border: none; border-left: 2px solid transparent; padding: 3px 8px; font: inherit; cursor: pointer; border-radius: 0 6px 6px 0; text-align: left; }
659
+ .dsh-cron-sidebar-job:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.04)); border-left-color: var(--dsh-cron-info); }
660
+ .dsh-cron-sidebar-job-title { font-size: 12px; color: var(--dsw-alias-label-secondary, #ccc); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
661
+ .dsh-cron-sidebar-job-meta { font-size: 11px; color: var(--dsw-alias-label-tertiary, #777); }
662
+ .dsh-cron-sidebar-empty, .dsh-cron-sidebar-more { font-size: 11px; color: var(--dsw-alias-label-tertiary, #777); padding: 2px 8px; }
663
+ .dsh-cron-task-highlight { border-color: var(--dsh-cron-info) !important; box-shadow: 0 0 0 2px var(--dsh-cron-info-bg); }
664
+
665
+ /* #39: narrow screens. Only layout changes — no information is hidden. */
666
+ @media (max-width: 900px) {
667
+ .dsh-cron-container { padding: 8px 12px 24px; gap: 14px; }
668
+ .dsh-cron-form-row { grid-template-columns: 1fr; }
669
+ .dsh-cron-channel-grid { grid-template-columns: 1fr; }
670
+ .dsh-cron-filter-panel { grid-template-columns: 1fr; }
671
+ .dsh-cron-modal { padding: 16px; border-radius: 12px; }
672
+ .dsh-cron-modal-scroll { max-height: 92vh; }
673
+ .dsh-cron-task-item { flex-wrap: wrap; gap: 10px; }
674
+ .dsh-cron-task-actions { margin-left: auto; }
675
+ .dsh-cron-history-list { max-height: 320px; }
676
+ }
677
+ @media (max-width: 640px) {
678
+ .dsh-cron-container { padding: 6px 8px 20px; }
679
+ .dsh-cron-top-bar { flex-wrap: wrap; gap: 8px; }
680
+ .dsh-cron-tabs { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; }
681
+ .dsh-cron-tab { white-space: nowrap; }
682
+ .dsh-cron-stats-bar { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
683
+ .dsh-cron-task-item { align-items: flex-start; padding: 12px; }
684
+ .dsh-cron-task-title { flex-wrap: wrap; row-gap: 4px; }
685
+ .dsh-cron-modal { padding: 14px; }
686
+ .dsh-cron-modal-foot { flex-direction: column-reverse; }
687
+ .dsh-cron-modal-foot > button { width: 100%; }
688
+ .dsh-cron-filter-panel { padding: 8px 10px; }
689
+ .dsh-cron-card { padding: 14px; }
690
+ /* 16px keeps iOS Safari from zooming the page when a field is focused. */
691
+ .dsh-cron-modal input, .dsh-cron-modal select, .dsh-cron-modal textarea,
692
+ .dsh-cron-card input, .dsh-cron-search-input, .dsh-cron-filter-field select { font-size: 16px; }
693
+ }
382
694
  `;
383
695
 
384
696
  function ensureStyles() {
@@ -475,12 +787,25 @@ window.__ModuleLoader__.load({
475
787
  const t = props.t || translate;
476
788
  const [tab, setTab] = React.useState('all');
477
789
  const [query, setQuery] = React.useState('');
790
+ // Facet filters (#40): applied client-side over the already-loaded list so
791
+ // toggling them is instant and does not touch the polling request.
792
+ const [filterType, setFilterType] = React.useState('');
793
+ const [filterModel, setFilterModel] = React.useState('');
794
+ const [filterChannel, setFilterChannel] = React.useState('');
795
+ const [filtersOpen, setFiltersOpen] = React.useState(false);
478
796
  const [tasks, setTasks] = React.useState([]);
479
797
  const [recs, setRecs] = React.useState([]);
480
798
  const [loading, setLoading] = React.useState(false);
481
799
  const [fetchError, setFetchError] = React.useState(null);
482
800
  const [dropdownOpen, setDropdownOpen] = React.useState(false);
483
801
 
802
+ // Export/import state (#42)
803
+ const [transferBusy, setTransferBusy] = React.useState(false);
804
+ const [importDoc, setImportDoc] = React.useState(null);
805
+ const [importSummary, setImportSummary] = React.useState(null);
806
+ const [importStrategy, setImportStrategy] = React.useState('skip');
807
+ const importFileRef = React.useRef(null);
808
+
484
809
  // Manual / Edit modal state
485
810
  const [manualModalOpen, setManualModalOpen] = React.useState(false);
486
811
  const [formId, setFormId] = React.useState(null);
@@ -496,6 +821,8 @@ window.__ModuleLoader__.load({
496
821
  const [taskHistory, setTaskHistory] = React.useState([]);
497
822
  const [historyLoading, setHistoryLoading] = React.useState(false);
498
823
  const [formNotifyTelegram, setFormNotifyTelegram] = React.useState(false);
824
+ const [formChannels, setFormChannels] = React.useState([]);
825
+ const [formTemplate, setFormTemplate] = React.useState('');
499
826
  const [formOnlyOnFailure, setFormOnlyOnFailure] = React.useState(false);
500
827
  const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
501
828
  const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
@@ -504,6 +831,23 @@ window.__ModuleLoader__.load({
504
831
  const [formMisfirePolicy, setFormMisfirePolicy] = React.useState('skip');
505
832
  const [formMaxRetries, setFormMaxRetries] = React.useState(0);
506
833
  const [formPermissionPreset, setFormPermissionPreset] = React.useState('default');
834
+ const [formEnv, setFormEnv] = React.useState('');
835
+ const [formCwd, setFormCwd] = React.useState('');
836
+ const [formWorkspaceId, setFormWorkspaceId] = React.useState('');
837
+ const [formWorktree, setFormWorktree] = React.useState(false);
838
+ const [formKeepWorktree, setFormKeepWorktree] = React.useState(false);
839
+ const [formHttpMethod, setFormHttpMethod] = React.useState('GET');
840
+ const [formHttpUrl, setFormHttpUrl] = React.useState('');
841
+ const [formHttpHeaders, setFormHttpHeaders] = React.useState('');
842
+ const [formHttpBody, setFormHttpBody] = React.useState('');
843
+ const [formSshProfileId, setFormSshProfileId] = React.useState('');
844
+ const [formSshTarget, setFormSshTarget] = React.useState('');
845
+ const [formSshKeyPath, setFormSshKeyPath] = React.useState('');
846
+ const [formDockerImage, setFormDockerImage] = React.useState('');
847
+ const [formNodePath, setFormNodePath] = React.useState('');
848
+ const [formPythonPath, setFormPythonPath] = React.useState('');
849
+ const [formSkillName, setFormSkillName] = React.useState('');
850
+ const [formWorkflowName, setFormWorkflowName] = React.useState('');
507
851
  const [stats, setStats] = React.useState(null);
508
852
 
509
853
  // Live execution indicator (#35): a light 1s tick only while some task
@@ -527,6 +871,8 @@ window.__ModuleLoader__.load({
527
871
  const [settingsNotifyGlobal, setSettingsNotifyGlobal] = React.useState(false);
528
872
  const [settingsOnlyOnFailureGlobal, setSettingsOnlyOnFailureGlobal] = React.useState(false);
529
873
  const [settingsKanbanUrl, setSettingsKanbanUrl] = React.useState('http://127.0.0.1:3000');
874
+ const [settingsDelivery, setSettingsDelivery] = React.useState({});
875
+ const [settingsSections, setSettingsSections] = React.useState({ channels: true });
530
876
  const [settingsHasDefault, setSettingsHasDefault] = React.useState(false);
531
877
  const [settingsHasCustomBotToken, setSettingsHasCustomBotToken] = React.useState(false);
532
878
  const [testStatus, setTestStatus] = React.useState(null);
@@ -556,6 +902,49 @@ window.__ModuleLoader__.load({
556
902
  return () => window.removeEventListener('keydown', onKey);
557
903
  }, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
558
904
 
905
+ /** #40: facet filtering happens client-side over the loaded list. */
906
+ const visibleTasks = React.useMemo(() => { let list = tasks;
907
+ if (filterType) list = list.filter((x) => (x.type || 'llm') === filterType);
908
+ if (filterModel) list = list.filter((x) => String(x.model || '').split('/').pop() === filterModel);
909
+ if (filterChannel) {
910
+ list = list.filter((x) => {
911
+ const channels = Array.isArray(x.channels) && x.channels.length
912
+ ? x.channels
913
+ : [x.notifyTelegram ? 'telegram' : null, (x.kanbanMode || 'none') !== 'none' ? 'kanban' : null].filter(Boolean);
914
+ return channels.includes(filterChannel);
915
+ });
916
+ }
917
+ return list;
918
+ }, [tasks, filterType, filterModel, filterChannel]);
919
+
920
+ const filterTypeOptions = React.useMemo(
921
+ () => Array.from(new Set(tasks.map((x) => x.type || 'llm'))).sort(),
922
+ [tasks]
923
+ );
924
+
925
+ const filterModelOptions = React.useMemo(
926
+ () => Array.from(new Set(tasks.map((x) => String(x.model || '').split('/').pop()).filter(Boolean))).sort(),
927
+ [tasks]
928
+ );
929
+
930
+ const resetFilters = () => {
931
+ setFilterType('');
932
+ setFilterModel('');
933
+ setFilterChannel('');
934
+ };
935
+
936
+ /** #34: scroll to and flash the task the sidebar list asked for. */
937
+ React.useEffect(() => {
938
+ if (!pendingTaskHighlight || tasks.length === 0) return;
939
+ const id = pendingTaskHighlight;
940
+ pendingTaskHighlight = null;
941
+ const node = document.querySelector('[data-task-id="' + id + '"]');
942
+ if (!node) return;
943
+ if (typeof node.scrollIntoView === 'function') node.scrollIntoView({ block: 'center' });
944
+ node.classList.add('dsh-cron-task-highlight');
945
+ setTimeout(() => { try { node.classList.remove('dsh-cron-task-highlight'); } catch (_) {} }, 2500);
946
+ }, [tasks]);
947
+
559
948
  const fetchTasks = async () => {
560
949
  setLoading(true);
561
950
  setFetchError(null);
@@ -600,8 +989,7 @@ window.__ModuleLoader__.load({
600
989
 
601
990
  React.useEffect(() => {
602
991
  fetchTasks();
603
- const pollInterval = setInterval(() => {
604
- // Background polling without resetting loading spinner
992
+ const pollInterval = setInterval(() => { // Background polling without resetting loading spinner
605
993
  fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query))
606
994
  .then(res => res.json())
607
995
  .then(data => {
@@ -634,13 +1022,128 @@ window.__ModuleLoader__.load({
634
1022
  }
635
1023
  };
636
1024
 
1025
+ /** #41: duplicate a task; the server copies config, resets run state (#41). */
1026
+ const handleDuplicate = async (task) => {
1027
+ try {
1028
+ const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/duplicate', { method: 'POST' });
1029
+ const data = await res.json();
1030
+ if (!data.ok) {
1031
+ alert(T_KEY(t, 'duplicate.failed', { error: (data && data.error) || '' }));
1032
+ return;
1033
+ }
1034
+ await fetchTasks();
1035
+ // A one-shot copy keeps its original schedule string, so warn instead
1036
+ // of letting the user resume a task pointing at a past moment.
1037
+ alert(task.oneShot ? T_KEY(t, 'duplicate.oneShotHint') : T_KEY(t, 'duplicate.done'));
1038
+ } catch (err) {
1039
+ alert(T_KEY(t, 'duplicate.failed', { error: err.message }));
1040
+ }
1041
+ };
1042
+
1043
+ /** A task id must not shadow a collection route (#42 review finding). */
1044
+ const RESERVED_TASK_IDS = ['export', 'import'];
1045
+
1046
+ /** Import needs the script-confirm header when the file has code tasks. */
1047
+ const importHeaders = (document_) => {
1048
+ const headers = { 'Content-Type': 'application/json' };
1049
+ const tasks = (document_ && Array.isArray(document_.tasks)) ? document_.tasks : [];
1050
+ if (tasks.some((x) => CODE_FORM_TYPES.includes(x && x.type))) headers[SCRIPT_CONFIRM_HEADER] = 'script';
1051
+ return headers;
1052
+ };
1053
+
1054
+ /** #42: download the task configuration as a JSON file. */
1055
+ const handleExport = async () => {
1056
+ setTransferBusy(true);
1057
+ try {
1058
+ const res = await fetch('/dsh-cron/tasks/export');
1059
+ const data = await res.json();
1060
+ if (!data.ok) {
1061
+ alert(T_KEY(t, 'transfer.exportFailed', { error: (data && data.error) || '' }));
1062
+ return;
1063
+ }
1064
+ const blob = new Blob([JSON.stringify(data.document, null, 2)], { type: 'application/json' });
1065
+ const url = URL.createObjectURL(blob);
1066
+ const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
1067
+ const anchor = document.createElement('a');
1068
+ anchor.href = url;
1069
+ anchor.download = `dsh-cron-tasks-${stamp}.json`;
1070
+ document.body.appendChild(anchor);
1071
+ anchor.click();
1072
+ document.body.removeChild(anchor);
1073
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1074
+ alert(T_KEY(t, 'transfer.exported', { count: data.count }));
1075
+ } catch (err) {
1076
+ alert(T_KEY(t, 'transfer.exportFailed', { error: err.message }));
1077
+ } finally {
1078
+ setTransferBusy(false);
1079
+ }
1080
+ };
1081
+
1082
+ /** #42: read the picked file, validate it server-side and show the plan. */
1083
+ const handleImportFile = async (e) => {
1084
+ const file = e.target.files && e.target.files[0];
1085
+ e.target.value = '';
1086
+ if (!file) return;
1087
+ setTransferBusy(true);
1088
+ try {
1089
+ const text = await file.text();
1090
+ let document_;
1091
+ try {
1092
+ document_ = JSON.parse(text);
1093
+ } catch (parseErr) {
1094
+ alert(T_KEY(t, 'transfer.badFile', { error: parseErr.message }));
1095
+ return;
1096
+ }
1097
+ const res = await fetch('/dsh-cron/tasks/import', {
1098
+ method: 'POST',
1099
+ headers: importHeaders(document_),
1100
+ body: JSON.stringify({ document: document_, dryRun: true, strategy: importStrategy })
1101
+ });
1102
+ const data = await res.json();
1103
+ if (!data.ok) {
1104
+ alert(T_KEY(t, 'transfer.importFailed', { error: (data && data.error) || '' }));
1105
+ return;
1106
+ }
1107
+ setImportDoc(document_);
1108
+ setImportSummary(data.summary);
1109
+ } catch (err) {
1110
+ alert(T_KEY(t, 'transfer.importFailed', { error: err.message }));
1111
+ } finally {
1112
+ setTransferBusy(false);
1113
+ }
1114
+ };
1115
+
1116
+ const handleImportConfirm = async () => {
1117
+ if (!importDoc) return;
1118
+ setTransferBusy(true);
1119
+ try {
1120
+ const res = await fetch('/dsh-cron/tasks/import', {
1121
+ method: 'POST',
1122
+ headers: importHeaders(importDoc),
1123
+ body: JSON.stringify({ document: importDoc, strategy: importStrategy })
1124
+ });
1125
+ const data = await res.json();
1126
+ if (!data.ok) {
1127
+ alert(T_KEY(t, 'transfer.importFailed', { error: (data && data.error) || '' }));
1128
+ return;
1129
+ }
1130
+ setImportDoc(null);
1131
+ setImportSummary(null);
1132
+ await fetchTasks();
1133
+ alert(T_KEY(t, 'transfer.imported', { count: data.imported }));
1134
+ } catch (err) {
1135
+ alert(T_KEY(t, 'transfer.importFailed', { error: err.message }));
1136
+ } finally {
1137
+ setTransferBusy(false);
1138
+ }
1139
+ };
1140
+
637
1141
  const handleDelete = async (id) => {
638
1142
  if (!confirm(T_KEY(t, 'confirm.delete'))) return;
639
1143
  try {
640
1144
  await fetch('/dsh-cron/tasks/' + encodeURIComponent(id), { method: 'DELETE' });
641
1145
  fetchTasks();
642
- } catch (err) {}
643
- };
1146
+ } catch (err) {} };
644
1147
 
645
1148
  const openSettingsModal = async () => {
646
1149
  setTestStatus(null);
@@ -654,6 +1157,7 @@ window.__ModuleLoader__.load({
654
1157
  setSettingsNotifyGlobal(Boolean(data.settings.notifyTelegram));
655
1158
  setSettingsOnlyOnFailureGlobal(Boolean(data.settings.onlyOnFailure));
656
1159
  setSettingsKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
1160
+ setSettingsDelivery(pickDeliverySettings(data.settings));
657
1161
  setSettingsHasDefault(Boolean(data.settings.hasDefaultCredentials));
658
1162
  setSettingsHasCustomBotToken(Boolean(data.settings.hasCustomBotToken));
659
1163
  }
@@ -666,13 +1170,13 @@ window.__ModuleLoader__.load({
666
1170
  const res = await fetch('/dsh-cron/settings', {
667
1171
  method: 'POST',
668
1172
  headers: { 'Content-Type': 'application/json' },
669
- body: JSON.stringify({
1173
+ body: JSON.stringify(Object.assign({
670
1174
  botToken: settingsBotToken,
671
1175
  chatId: settingsChatId,
672
1176
  notifyTelegram: settingsNotifyGlobal,
673
1177
  onlyOnFailure: settingsOnlyOnFailureGlobal,
674
1178
  kanbanBaseUrl: settingsKanbanUrl,
675
- })
1179
+ }, settingsDelivery))
676
1180
  });
677
1181
  const data = await res.json();
678
1182
  if (data && data.ok) {
@@ -685,8 +1189,35 @@ window.__ModuleLoader__.load({
685
1189
  }
686
1190
  };
687
1191
 
688
- const handleTestTelegram = async () => {
689
- setTestStatus({ loading: true, message: T_KEY(t, 'settings.sending') });
1192
+ /**
1193
+ * Update one delivery setting without dropping the rest of the object.
1194
+ * `undefined` removes the key so the server default applies again; an
1195
+ * empty string is kept so a text field can be cleared on purpose.
1196
+ */
1197
+ const updateDelivery = (key, value) => {
1198
+ setSettingsDelivery((prev) => {
1199
+ const next = Object.assign({}, prev);
1200
+ if (value === undefined) delete next[key];
1201
+ else next[key] = value;
1202
+ return next;
1203
+ });
1204
+ };
1205
+
1206
+ /** Per-channel template overrides live in a nested map on the server. */
1207
+ const updateChannelTemplate = (channelId, value) => {
1208
+ setSettingsDelivery((prev) => {
1209
+ const templates = Object.assign({}, prev.channelTemplates || {});
1210
+ if (value && value.trim()) templates[channelId] = value;
1211
+ else delete templates[channelId];
1212
+ return Object.assign({}, prev, { channelTemplates: templates });
1213
+ });
1214
+ };
1215
+
1216
+ const toggleDeliverySection = (key) => {
1217
+ setSettingsSections((prev) => Object.assign({}, prev, { [key]: !prev[key] }));
1218
+ };
1219
+
1220
+ const handleTestTelegram = async () => { setTestStatus({ loading: true, message: T_KEY(t, 'settings.sending') });
690
1221
  try {
691
1222
  const res = await fetch('/dsh-cron/telegram/test', {
692
1223
  method: 'POST',
@@ -714,6 +1245,8 @@ window.__ModuleLoader__.load({
714
1245
  setFormType('llm');
715
1246
  setFormDelivery('isolated');
716
1247
  setFormNotifyTelegram(false);
1248
+ setFormChannels([]);
1249
+ setFormTemplate('');
717
1250
  setFormOnlyOnFailure(false);
718
1251
  setFormTimeoutSeconds(1800);
719
1252
  setFormOverlapPolicy('skip');
@@ -722,6 +1255,23 @@ window.__ModuleLoader__.load({
722
1255
  setFormMisfirePolicy('skip');
723
1256
  setFormMaxRetries(0);
724
1257
  setFormPermissionPreset('default');
1258
+ setFormEnv('');
1259
+ setFormCwd('');
1260
+ setFormWorkspaceId('');
1261
+ setFormWorktree(false);
1262
+ setFormKeepWorktree(false);
1263
+ setFormHttpMethod('GET');
1264
+ setFormHttpUrl('');
1265
+ setFormHttpHeaders('');
1266
+ setFormHttpBody('');
1267
+ setFormSshProfileId('');
1268
+ setFormSshTarget('');
1269
+ setFormSshKeyPath('');
1270
+ setFormDockerImage('');
1271
+ setFormNodePath('');
1272
+ setFormPythonPath('');
1273
+ setFormSkillName('');
1274
+ setFormWorkflowName('');
725
1275
  if (preset) {
726
1276
  setFormTitle(preset.title || '');
727
1277
  setFormSchedule(preset.schedule || '');
@@ -768,6 +1318,8 @@ window.__ModuleLoader__.load({
768
1318
  setFormModel(task.model || '');
769
1319
  setFormDelivery(task.delivery || 'isolated');
770
1320
  setFormNotifyTelegram(Boolean(task.notifyTelegram));
1321
+ setFormChannels(Array.isArray(task.channels) ? task.channels.filter((c) => DELIVERY_CHANNELS.includes(c)) : []);
1322
+ setFormTemplate(task.template || '');
771
1323
  setFormOnlyOnFailure(Boolean(task.onlyOnFailure));
772
1324
  setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
773
1325
  setFormOverlapPolicy(task.overlapPolicy || 'skip');
@@ -776,6 +1328,23 @@ window.__ModuleLoader__.load({
776
1328
  setFormMisfirePolicy(task.misfirePolicy || 'skip');
777
1329
  setFormMaxRetries(task.maxRetries !== undefined ? task.maxRetries : 0);
778
1330
  setFormPermissionPreset(task.permissionPreset || 'default');
1331
+ setFormEnv(formatEnvText(task.env));
1332
+ setFormCwd(task.cwd || '');
1333
+ setFormWorkspaceId(task.workspaceId || '');
1334
+ setFormWorktree(Boolean(task.worktree));
1335
+ setFormKeepWorktree(Boolean(task.keepWorktree));
1336
+ setFormHttpMethod(task.httpMethod || 'GET');
1337
+ setFormHttpUrl(task.httpUrl || '');
1338
+ setFormHttpHeaders(typeof task.httpHeaders === 'string' ? task.httpHeaders : (task.httpHeaders ? JSON.stringify(task.httpHeaders) : ''));
1339
+ setFormHttpBody(task.httpBody || '');
1340
+ setFormSshProfileId(task.sshProfileId || '');
1341
+ setFormSshTarget(task.sshTarget || '');
1342
+ setFormSshKeyPath(task.sshKeyPath || '');
1343
+ setFormDockerImage(task.dockerImage || '');
1344
+ setFormNodePath(task.nodePath || '');
1345
+ setFormPythonPath(task.pythonPath || '');
1346
+ setFormSkillName(task.skillName || '');
1347
+ setFormWorkflowName(task.workflowName || '');
779
1348
  if (task.provider) loadModels(task.provider);
780
1349
  else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
781
1350
  loadTaskHistory(task.id);
@@ -792,17 +1361,20 @@ window.__ModuleLoader__.load({
792
1361
  const handleSaveManual = async (e) => {
793
1362
  e.preventDefault();
794
1363
  try {
1364
+ const isAgentType = AGENT_FORM_TYPES.includes(formType);
795
1365
  const payload = {
796
1366
  id: formId || undefined,
797
1367
  status: selectedTaskMeta ? selectedTaskMeta.status : 'active',
798
1368
  title: formTitle,
799
1369
  schedule: formSchedule,
800
- prompt: formPrompt,
1370
+ prompt: formType === 'http' ? (formPrompt.trim() || formHttpUrl.trim()) : formPrompt,
801
1371
  type: formType,
802
1372
  delivery: formDelivery || 'isolated',
803
- provider: formType === 'script' ? undefined : (formProvider || undefined),
804
- model: formType === 'script' ? undefined : (formModel || undefined),
1373
+ provider: isAgentType ? (formProvider || undefined) : undefined,
1374
+ model: isAgentType ? (formModel || undefined) : undefined,
805
1375
  notifyTelegram: formNotifyTelegram,
1376
+ channels: formChannels,
1377
+ template: formTemplate.trim(),
806
1378
  onlyOnFailure: formOnlyOnFailure,
807
1379
  timeoutSeconds: Number(formTimeoutSeconds) || 1800,
808
1380
  overlapPolicy: formOverlapPolicy,
@@ -811,11 +1383,28 @@ window.__ModuleLoader__.load({
811
1383
  maxRetries: Number(formMaxRetries) || 0,
812
1384
  permissionPreset: formPermissionPreset,
813
1385
  kanbanMode: formKanbanMode,
1386
+ env: parseEnvText(formEnv),
1387
+ cwd: formCwd.trim(),
1388
+ workspaceId: formWorkspaceId.trim(),
1389
+ worktree: isAgentType ? Boolean(formWorktree) : false,
1390
+ keepWorktree: isAgentType ? Boolean(formKeepWorktree) : false,
1391
+ httpMethod: formHttpMethod,
1392
+ httpUrl: formHttpUrl.trim(),
1393
+ httpHeaders: formHttpHeaders.trim() || undefined,
1394
+ httpBody: formHttpBody,
1395
+ sshProfileId: formSshProfileId.trim(),
1396
+ sshTarget: formSshTarget.trim(),
1397
+ sshKeyPath: formSshKeyPath.trim(),
1398
+ dockerImage: formDockerImage.trim(),
1399
+ nodePath: formNodePath.trim(),
1400
+ pythonPath: formPythonPath.trim(),
1401
+ skillName: formSkillName.trim(),
1402
+ workflowName: formWorkflowName.trim(),
814
1403
  };
815
- // Cross-origin hardening: script tasks need the explicit confirm
816
- // header, which browsers cannot attach to forged cross-site posts (#86)
1404
+ // Cross-origin hardening: every code-executing type needs the explicit
1405
+ // confirm header, which forged cross-site posts cannot attach (#86)
817
1406
  const headers = { 'Content-Type': 'application/json' };
818
- if (formType === 'script') headers[SCRIPT_CONFIRM_HEADER] = 'script';
1407
+ if (CODE_FORM_TYPES.includes(formType)) headers[SCRIPT_CONFIRM_HEADER] = 'script';
819
1408
  const res = await fetch('/dsh-cron/tasks', {
820
1409
  method: 'POST',
821
1410
  headers,
@@ -905,7 +1494,31 @@ window.__ModuleLoader__.load({
905
1494
  React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: openDshModal }, T_KEY(t, 'actions.createWithDsh')),
906
1495
  React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: () => openManualModal(null) }, T_KEY(t, 'actions.setupManually'))
907
1496
  )
908
- )
1497
+ ),
1498
+ // #42: export/import as a small pair of icon buttons.
1499
+ React.createElement('button', {
1500
+ type: 'button',
1501
+ className: 'dsh-cron-icon-btn',
1502
+ title: T_KEY(t, 'transfer.exportTitle'),
1503
+ 'aria-label': T_KEY(t, 'transfer.exportTitle'),
1504
+ disabled: transferBusy,
1505
+ onClick: handleExport
1506
+ }, React.createElement('span', svgProps(ICON_DOWNLOAD))),
1507
+ React.createElement('button', {
1508
+ type: 'button',
1509
+ className: 'dsh-cron-icon-btn',
1510
+ title: T_KEY(t, 'transfer.importTitle'),
1511
+ 'aria-label': T_KEY(t, 'transfer.importTitle'),
1512
+ disabled: transferBusy,
1513
+ onClick: () => { if (importFileRef.current) importFileRef.current.click(); }
1514
+ }, React.createElement('span', svgProps(ICON_UPLOAD))),
1515
+ React.createElement('input', {
1516
+ type: 'file',
1517
+ accept: '.json,application/json',
1518
+ ref: importFileRef,
1519
+ style: { display: 'none' },
1520
+ onChange: handleImportFile
1521
+ })
909
1522
  )
910
1523
  ),
911
1524
  React.createElement('div', { className: 'dsh-cron-search-bar' },
@@ -951,6 +1564,50 @@ window.__ModuleLoader__.load({
951
1564
  : T_KEY(t, 'tabs.completed'))
952
1565
  )
953
1566
  ),
1567
+ // #40: facet filters over the loaded list.
1568
+ React.createElement('div', { className: 'dsh-cron-filters' },
1569
+ React.createElement('button', {
1570
+ type: 'button',
1571
+ className: 'dsh-cron-filter-toggle',
1572
+ 'aria-expanded': filtersOpen ? 'true' : 'false',
1573
+ onClick: () => setFiltersOpen(!filtersOpen)
1574
+ },
1575
+ T_KEY(t, 'filters.title'),
1576
+ (filterType || filterModel || filterChannel) ? ' •' : ''
1577
+ ),
1578
+ filtersOpen && React.createElement('div', { className: 'dsh-cron-filter-panel' },
1579
+ React.createElement('label', { className: 'dsh-cron-filter-field' },
1580
+ React.createElement('span', null, T_KEY(t, 'filters.type')),
1581
+ React.createElement('select', {
1582
+ value: filterType,
1583
+ onChange: (e) => setFilterType(e.target.value)
1584
+ },
1585
+ React.createElement('option', { value: '' }, T_KEY(t, 'filters.any')),
1586
+ ...filterTypeOptions.map((value) => React.createElement('option', { key: value, value }, value))
1587
+ )
1588
+ ),
1589
+ React.createElement('label', { className: 'dsh-cron-filter-field' },
1590
+ React.createElement('span', null, T_KEY(t, 'filters.model')),
1591
+ React.createElement('select', {
1592
+ value: filterModel,
1593
+ onChange: (e) => setFilterModel(e.target.value)
1594
+ },
1595
+ React.createElement('option', { value: '' }, T_KEY(t, 'filters.any')),
1596
+ ...filterModelOptions.map((value) => React.createElement('option', { key: value, value }, value))
1597
+ )
1598
+ ),
1599
+ React.createElement('label', { className: 'dsh-cron-filter-field' },
1600
+ React.createElement('span', null, T_KEY(t, 'filters.channel')),
1601
+ React.createElement('select', {
1602
+ value: filterChannel,
1603
+ onChange: (e) => setFilterChannel(e.target.value)
1604
+ },
1605
+ React.createElement('option', { value: '' }, T_KEY(t, 'filters.any')),
1606
+ ...DELIVERY_CHANNELS.map((id) => React.createElement('option', { key: id, value: id }, T_KEY(t, CHANNEL_LABEL_KEYS[id])))
1607
+ )
1608
+ )
1609
+ )
1610
+ ),
954
1611
  fetchError ? React.createElement('div', {
955
1612
  className: 'dsh-cron-status-banner',
956
1613
  'data-type': 'error',
@@ -964,15 +1621,38 @@ window.__ModuleLoader__.load({
964
1621
  onClick: () => fetchTasks()
965
1622
  }, T_KEY(t, 'banner.retry'))
966
1623
  ) : null,
1624
+ (filterType || filterModel || filterChannel) ? React.createElement('div', {
1625
+ className: 'dsh-cron-filter-summary',
1626
+ style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary, #888)', marginBottom: '8px', display: 'flex', gap: '8px', alignItems: 'center' }
1627
+ },
1628
+ React.createElement('span', null, T_KEY(t, 'filters.showing', { shown: visibleTasks.length, total: tasks.length })),
1629
+ React.createElement('button', {
1630
+ type: 'button',
1631
+ className: 'dsh-cron-btn-secondary',
1632
+ style: { padding: '2px 8px', fontSize: '11px' },
1633
+ onClick: resetFilters
1634
+ }, T_KEY(t, 'filters.reset'))
1635
+ ) : null,
967
1636
  React.createElement('div', { className: 'dsh-cron-task-list' },
968
1637
  loading && tasks.length === 0
969
1638
  ? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #888)', padding: '16px 0', fontSize: '14px' } }, T_KEY(t, 'list.loading'))
970
1639
  : tasks.length === 0
971
1640
  ? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #777)', padding: '16px 0', fontSize: '14px' } }, T_KEY(t, 'list.empty'))
972
- : tasks.map((task) =>
1641
+ : visibleTasks.length === 0
1642
+ ? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #777)', padding: '16px 0', fontSize: '14px', display: 'flex', gap: '10px', alignItems: 'center' } },
1643
+ React.createElement('span', null, T_KEY(t, 'filters.empty')),
1644
+ React.createElement('button', {
1645
+ type: 'button',
1646
+ className: 'dsh-cron-btn-secondary',
1647
+ style: { padding: '2px 8px', fontSize: '12px' },
1648
+ onClick: resetFilters
1649
+ }, T_KEY(t, 'filters.reset'))
1650
+ )
1651
+ : visibleTasks.map((task) =>
973
1652
  React.createElement('div', {
974
1653
  key: task.id,
975
1654
  className: 'dsh-cron-task-item',
1655
+ 'data-task-id': task.id,
976
1656
  role: 'button',
977
1657
  tabIndex: 0,
978
1658
  'aria-label': task.title,
@@ -1035,6 +1715,13 @@ window.__ModuleLoader__.load({
1035
1715
  'aria-label': T_KEY(t, 'actions.edit'),
1036
1716
  onClick: () => openEditModal(task)
1037
1717
  }, React.createElement('span', svgProps(ICON_EDIT))),
1718
+ React.createElement('button', {
1719
+ type: 'button',
1720
+ className: 'dsh-cron-icon-btn',
1721
+ title: T_KEY(t, 'actions.duplicate'),
1722
+ 'aria-label': T_KEY(t, 'actions.duplicate'),
1723
+ onClick: () => handleDuplicate(task)
1724
+ }, React.createElement('span', svgProps(ICON_COPY))),
1038
1725
  React.createElement('button', {
1039
1726
  type: 'button',
1040
1727
  className: 'dsh-cron-icon-btn dsh-cron-danger',
@@ -1176,7 +1863,14 @@ window.__ModuleLoader__.load({
1176
1863
  onChange: (e) => setFormType(e.target.value)
1177
1864
  },
1178
1865
  React.createElement('option', { value: 'llm' }, T_KEY(t, 'form.typeLlm')),
1179
- React.createElement('option', { value: 'script' }, T_KEY(t, 'form.typeScript'))
1866
+ React.createElement('option', { value: 'script' }, T_KEY(t, 'form.typeScript')),
1867
+ React.createElement('option', { value: 'node' }, T_KEY(t, 'form.typeNode')),
1868
+ React.createElement('option', { value: 'python' }, T_KEY(t, 'form.typePython')),
1869
+ React.createElement('option', { value: 'http' }, T_KEY(t, 'form.typeHttp')),
1870
+ React.createElement('option', { value: 'ssh' }, T_KEY(t, 'form.typeSsh')),
1871
+ React.createElement('option', { value: 'docker' }, T_KEY(t, 'form.typeDocker')),
1872
+ React.createElement('option', { value: 'skill' }, T_KEY(t, 'form.typeSkill')),
1873
+ React.createElement('option', { value: 'workflow' }, T_KEY(t, 'form.typeWorkflow'))
1180
1874
  )
1181
1875
  ),
1182
1876
  React.createElement('div', { className: 'dsh-cron-form-group' },
@@ -1268,7 +1962,7 @@ window.__ModuleLoader__.load({
1268
1962
  React.createElement('option', { value: 'always' }, T_KEY(t, 'form.kanbanAlways'))
1269
1963
  )
1270
1964
  ),
1271
- formType !== 'script' && React.createElement('div', { className: 'dsh-cron-form-row' },
1965
+ AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-row' },
1272
1966
  React.createElement('div', { className: 'dsh-cron-form-group' },
1273
1967
  React.createElement('label', null, T_KEY(t, 'form.providerLabel')),
1274
1968
  React.createElement('select', {
@@ -1290,14 +1984,165 @@ window.__ModuleLoader__.load({
1290
1984
  )
1291
1985
  )
1292
1986
  ),
1293
- React.createElement('div', { className: 'dsh-cron-form-group' },
1294
- React.createElement('label', null, formType === 'script' ? T_KEY(t, 'form.promptLabelScript') : T_KEY(t, 'form.promptLabelLlm')),
1987
+ formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
1988
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1989
+ React.createElement('label', null, T_KEY(t, 'form.httpMethodLabel')),
1990
+ React.createElement('select', {
1991
+ value: formHttpMethod,
1992
+ onChange: (e) => setFormHttpMethod(e.target.value)
1993
+ },
1994
+ ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].map((m) => React.createElement('option', { key: m, value: m }, m))
1995
+ )
1996
+ ),
1997
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1998
+ React.createElement('label', null, T_KEY(t, 'form.httpUrlLabel')),
1999
+ React.createElement('input', {
2000
+ required: true,
2001
+ value: formHttpUrl,
2002
+ placeholder: 'https://example.test/webhook',
2003
+ onChange: (e) => setFormHttpUrl(e.target.value)
2004
+ })
2005
+ )
2006
+ ),
2007
+ formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
2008
+ React.createElement('div', { className: 'dsh-cron-form-group' },
2009
+ React.createElement('label', null, T_KEY(t, 'form.httpHeadersLabel')),
2010
+ React.createElement('input', {
2011
+ value: formHttpHeaders,
2012
+ placeholder: '{"Authorization":"Bearer ..."}',
2013
+ onChange: (e) => setFormHttpHeaders(e.target.value)
2014
+ })
2015
+ ),
2016
+ React.createElement('div', { className: 'dsh-cron-form-group' },
2017
+ React.createElement('label', null, T_KEY(t, 'form.httpBodyLabel')),
2018
+ React.createElement('input', {
2019
+ value: formHttpBody,
2020
+ onChange: (e) => setFormHttpBody(e.target.value)
2021
+ })
2022
+ )
2023
+ ),
2024
+ formType === 'ssh' && React.createElement('div', { className: 'dsh-cron-form-row' },
2025
+ React.createElement('div', { className: 'dsh-cron-form-group' },
2026
+ React.createElement('label', null, T_KEY(t, 'form.sshProfileLabel')),
2027
+ React.createElement('input', {
2028
+ value: formSshProfileId,
2029
+ placeholder: 'prod-web',
2030
+ onChange: (e) => setFormSshProfileId(e.target.value)
2031
+ }),
2032
+ React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.sshProfileHint'))
2033
+ ),
2034
+ React.createElement('div', { className: 'dsh-cron-form-group' },
2035
+ React.createElement('label', null, T_KEY(t, 'form.sshTargetLabel')),
2036
+ React.createElement('input', {
2037
+ value: formSshTarget,
2038
+ placeholder: 'root@10.0.0.5',
2039
+ onChange: (e) => setFormSshTarget(e.target.value)
2040
+ }),
2041
+ React.createElement('input', {
2042
+ style: { marginTop: '6px' },
2043
+ value: formSshKeyPath,
2044
+ placeholder: '~/.ssh/id_ed25519',
2045
+ onChange: (e) => setFormSshKeyPath(e.target.value)
2046
+ })
2047
+ )
2048
+ ),
2049
+ formType === 'docker' && React.createElement('div', { className: 'dsh-cron-form-group' },
2050
+ React.createElement('label', null, T_KEY(t, 'form.dockerImageLabel')),
2051
+ React.createElement('input', {
2052
+ required: true,
2053
+ value: formDockerImage,
2054
+ placeholder: 'alpine:3.20',
2055
+ onChange: (e) => setFormDockerImage(e.target.value)
2056
+ })
2057
+ ),
2058
+ formType === 'skill' && React.createElement('div', { className: 'dsh-cron-form-group' },
2059
+ React.createElement('label', null, T_KEY(t, 'form.skillNameLabel')),
2060
+ React.createElement('input', {
2061
+ value: formSkillName,
2062
+ placeholder: 'gitea',
2063
+ onChange: (e) => setFormSkillName(e.target.value)
2064
+ })
2065
+ ),
2066
+ formType === 'workflow' && React.createElement('div', { className: 'dsh-cron-form-group' },
2067
+ React.createElement('label', null, T_KEY(t, 'form.workflowNameLabel')),
2068
+ React.createElement('input', {
2069
+ value: formWorkflowName,
2070
+ placeholder: 'nightly-release',
2071
+ onChange: (e) => setFormWorkflowName(e.target.value)
2072
+ })
2073
+ ),
2074
+ (formType === 'node' || formType === 'python') && React.createElement('div', { className: 'dsh-cron-form-group' },
2075
+ React.createElement('label', null, formType === 'node' ? 'Node.js binary (optional)' : 'Python interpreter (optional)'),
2076
+ React.createElement('input', {
2077
+ value: formType === 'node' ? formNodePath : formPythonPath,
2078
+ placeholder: formType === 'node' ? 'auto' : '.venv/bin/python',
2079
+ onChange: (e) => (formType === 'node' ? setFormNodePath(e.target.value) : setFormPythonPath(e.target.value))
2080
+ })
2081
+ ),
2082
+ !AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-group' },
2083
+ React.createElement('label', null, T_KEY(t, 'form.envLabel')),
2084
+ React.createElement('textarea', {
2085
+ rows: 3,
2086
+ style: { fontFamily: 'monospace', fontSize: '12.5px' },
2087
+ value: formEnv,
2088
+ placeholder: T_KEY(t, 'form.envPlaceholder'),
2089
+ onChange: (e) => setFormEnv(e.target.value)
2090
+ }),
2091
+ React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.envHint'))
2092
+ ),
2093
+ React.createElement('div', { className: 'dsh-cron-form-row' },
2094
+ React.createElement('div', { className: 'dsh-cron-form-group' },
2095
+ React.createElement('label', null, T_KEY(t, 'form.cwdLabel')),
2096
+ React.createElement('input', {
2097
+ value: formCwd,
2098
+ placeholder: '/srv/app',
2099
+ onChange: (e) => setFormCwd(e.target.value)
2100
+ })
2101
+ ),
2102
+ React.createElement('div', { className: 'dsh-cron-form-group' },
2103
+ React.createElement('label', null, T_KEY(t, 'form.workspaceLabel')),
2104
+ React.createElement('input', {
2105
+ value: formWorkspaceId,
2106
+ onChange: (e) => setFormWorkspaceId(e.target.value)
2107
+ })
2108
+ )
2109
+ ),
2110
+ AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-group' },
2111
+ React.createElement('label', { className: 'dsh-cron-checkbox-row' },
2112
+ React.createElement('input', {
2113
+ type: 'checkbox',
2114
+ checked: formWorktree,
2115
+ onChange: (e) => setFormWorktree(e.target.checked)
2116
+ }),
2117
+ React.createElement('span', null, T_KEY(t, 'form.worktreeLabel'))
2118
+ ),
2119
+ formWorktree && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
2120
+ React.createElement('input', {
2121
+ type: 'checkbox',
2122
+ checked: formKeepWorktree,
2123
+ onChange: (e) => setFormKeepWorktree(e.target.checked)
2124
+ }),
2125
+ React.createElement('span', null, T_KEY(t, 'form.keepWorktreeLabel'))
2126
+ )
2127
+ ),
2128
+ formType !== 'http' && React.createElement('div', { className: 'dsh-cron-form-group' },
2129
+ React.createElement('label', null, AGENT_FORM_TYPES.includes(formType) ? T_KEY(t, 'form.promptLabelLlm') : T_KEY(t, 'form.promptLabelScript')),
1295
2130
  React.createElement('textarea', {
1296
2131
  required: true,
1297
2132
  rows: 9,
1298
2133
  style: { fontFamily: 'monospace', fontSize: '12.5px', lineHeight: '1.45' },
1299
2134
  value: formPrompt,
1300
- placeholder: formType === 'script' ? T_KEY(t, 'form.promptPlaceholderScript') : T_KEY(t, 'form.promptPlaceholderLlm'),
2135
+ placeholder: AGENT_FORM_TYPES.includes(formType)
2136
+ ? T_KEY(t, 'form.promptPlaceholderLlm')
2137
+ : formType === 'docker'
2138
+ ? 'python -V'
2139
+ : formType === 'ssh'
2140
+ ? 'uname -a'
2141
+ : formType === 'node'
2142
+ ? 'console.log("hello")'
2143
+ : formType === 'python'
2144
+ ? 'print("hello")'
2145
+ : T_KEY(t, 'form.promptPlaceholderScript'),
1301
2146
  onChange: (e) => setFormPrompt(e.target.value)
1302
2147
  })
1303
2148
  ),
@@ -1322,13 +2167,43 @@ window.__ModuleLoader__.load({
1322
2167
  }),
1323
2168
  React.createElement('span', null, T_KEY(t, 'form.notifyTelegram'))
1324
2169
  ),
1325
- formNotifyTelegram && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
2170
+ (formNotifyTelegram || formChannels.length > 0) && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
1326
2171
  React.createElement('input', {
1327
2172
  type: 'checkbox',
1328
2173
  checked: formOnlyOnFailure,
1329
2174
  onChange: (e) => setFormOnlyOnFailure(e.target.checked)
1330
2175
  }),
1331
2176
  React.createElement('span', null, T_KEY(t, 'form.notifyOnlyFailure'))
2177
+ ),
2178
+ React.createElement('div', { style: { marginTop: '12px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l2, #2e2e2e)' } },
2179
+ React.createElement('div', { style: { fontWeight: 500, fontSize: '12.5px', color: 'var(--dsw-alias-label-secondary, #aaa)', marginBottom: '8px' } }, T_KEY(t, 'form.channelsTitle')),
2180
+ React.createElement('div', { className: 'dsh-cron-channel-grid' },
2181
+ ...DELIVERY_CHANNELS.map((id) => React.createElement('label', { key: id, className: 'dsh-cron-checkbox-row' },
2182
+ React.createElement('input', {
2183
+ type: 'checkbox',
2184
+ checked: formChannels.indexOf(id) >= 0,
2185
+ onChange: (e) => {
2186
+ setFormChannels((prev) => {
2187
+ const set = new Set(prev);
2188
+ if (e.target.checked) set.add(id); else set.delete(id);
2189
+ return DELIVERY_CHANNELS.filter((c) => set.has(c));
2190
+ });
2191
+ }
2192
+ }),
2193
+ React.createElement('span', null, T_KEY(t, CHANNEL_LABEL_KEYS[id]))
2194
+ ))
2195
+ ),
2196
+ React.createElement('div', { className: 'dsh-cron-hint', style: { marginTop: '6px' } }, T_KEY(t, 'form.channelsHint'))
2197
+ ),
2198
+ React.createElement('div', { className: 'dsh-cron-form-group', style: { marginTop: '12px', marginBottom: 0 } },
2199
+ React.createElement('label', null, T_KEY(t, 'form.templateLabel')),
2200
+ React.createElement('input', {
2201
+ type: 'text',
2202
+ value: formTemplate,
2203
+ placeholder: T_KEY(t, 'form.templatePlaceholder'),
2204
+ onChange: (e) => setFormTemplate(e.target.value)
2205
+ }),
2206
+ React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.templateHint'))
1332
2207
  )
1333
2208
  ),
1334
2209
  selectedTaskMeta && React.createElement('div', {
@@ -1376,7 +2251,7 @@ window.__ModuleLoader__.load({
1376
2251
 
1377
2252
  // 2. Settings modal
1378
2253
  settingsModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setSettingsModalOpen(false) },
1379
- React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
2254
+ React.createElement('div', { className: 'dsh-cron-modal dsh-cron-modal-scroll', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
1380
2255
  React.createElement('div', { className: 'dsh-cron-modal-head', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '18px' } },
1381
2256
  React.createElement('div', { className: 'dsh-cron-modal-title', style: { margin: 0, display: 'flex', alignItems: 'center', gap: '8px' } },
1382
2257
  React.createElement('span', { style: { display: 'flex', alignItems: 'center', color: 'var(--dsh-cron-info)' }, ...iconHtml(ICON_TELEGRAM) }),
@@ -1425,6 +2300,14 @@ window.__ModuleLoader__.load({
1425
2300
  React.createElement('span', null, T_KEY(t, 'settings.globalOnlyFailure'))
1426
2301
  )
1427
2302
  ),
2303
+ ...renderDeliverySettings({
2304
+ T: (key, vars) => T_KEY(t, key, vars),
2305
+ values: settingsDelivery,
2306
+ onChange: updateDelivery,
2307
+ onChannelTemplate: updateChannelTemplate,
2308
+ sections: settingsSections,
2309
+ onToggleSection: toggleDeliverySection,
2310
+ }),
1428
2311
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '14px' } },
1429
2312
  React.createElement('button', {
1430
2313
  type: 'button',
@@ -1453,7 +2336,50 @@ window.__ModuleLoader__.load({
1453
2336
  )
1454
2337
  ),
1455
2338
 
1456
- // 3. "Create with DSH" modal
2339
+ // 3. Import modal (#42) — summary first, then an explicit strategy.
2340
+ importDoc && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !transferBusy && setImportDoc(null) },
2341
+ React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
2342
+ React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'transfer.importTitleModal')),
2343
+ React.createElement('div', { className: 'dsh-cron-hint', style: { marginBottom: '10px' } }, T_KEY(t, 'transfer.importHint')),
2344
+ importSummary && React.createElement('div', null,
2345
+ React.createElement('div', { style: { fontSize: '13px', marginBottom: '12px' } },
2346
+ T_KEY(t, 'transfer.summary', {
2347
+ total: (importSummary.add || 0) + (importSummary.replace || 0) + (importSummary.skip || 0),
2348
+ add: importSummary.add || 0,
2349
+ replace: importSummary.replace || 0,
2350
+ skip: importSummary.skip || 0
2351
+ })
2352
+ ),
2353
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '14px' } },
2354
+ ...['add', 'replace', 'skip'].map((mode) => React.createElement('label', { key: mode, className: 'dsh-cron-checkbox-row' },
2355
+ React.createElement('input', {
2356
+ type: 'radio',
2357
+ name: 'dsh-cron-import-strategy',
2358
+ checked: importStrategy === mode,
2359
+ onChange: () => setImportStrategy(mode)
2360
+ }),
2361
+ React.createElement('span', null, T_KEY(t, mode === 'add' ? 'transfer.strategyAdd' : mode === 'replace' ? 'transfer.strategyReplace' : 'transfer.strategySkip'))
2362
+ ))
2363
+ )
2364
+ ),
2365
+ React.createElement('div', { className: 'dsh-cron-modal-foot' },
2366
+ React.createElement('button', {
2367
+ type: 'button',
2368
+ className: 'dsh-cron-btn-secondary',
2369
+ disabled: transferBusy,
2370
+ onClick: () => setImportDoc(null)
2371
+ }, T_KEY(t, 'modal.cancel')),
2372
+ React.createElement('button', {
2373
+ type: 'button',
2374
+ className: 'dsh-cron-btn-primary',
2375
+ disabled: transferBusy || !importSummary,
2376
+ onClick: handleImportConfirm
2377
+ }, T_KEY(t, 'transfer.confirm'))
2378
+ )
2379
+ )
2380
+ ),
2381
+
2382
+ // 4. "Create with DSH" modal
1457
2383
  dshModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !dshLoading && setDshModalOpen(false) },
1458
2384
  React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
1459
2385
  React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'dsh.modalTitle')),
@@ -1510,6 +2436,8 @@ window.__ModuleLoader__.load({
1510
2436
  const [notify, setNotify] = React.useState(false);
1511
2437
  const [onlyFailure, setOnlyFailure] = React.useState(false);
1512
2438
  const [kanbanUrl, setKanbanUrl] = React.useState('http://127.0.0.1:3000');
2439
+ const [delivery, setDelivery] = React.useState({});
2440
+ const [sections, setSections] = React.useState({ channels: true });
1513
2441
  const [hasDefault, setHasDefault] = React.useState(false);
1514
2442
  const [saved, setSaved] = React.useState(false);
1515
2443
  const [testInfo, setTestInfo] = React.useState(null);
@@ -1534,6 +2462,7 @@ window.__ModuleLoader__.load({
1534
2462
  setNotify(Boolean(data.settings.notifyTelegram));
1535
2463
  setOnlyFailure(Boolean(data.settings.onlyOnFailure));
1536
2464
  setKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
2465
+ setDelivery(pickDeliverySettings(data.settings));
1537
2466
  setHasDefault(Boolean(data.settings.hasDefaultCredentials));
1538
2467
  setLoadState('ready');
1539
2468
  } else {
@@ -1550,13 +2479,13 @@ window.__ModuleLoader__.load({
1550
2479
  const res = await fetch('/dsh-cron/settings', {
1551
2480
  method: 'POST',
1552
2481
  headers: { 'Content-Type': 'application/json' },
1553
- body: JSON.stringify({
2482
+ body: JSON.stringify(Object.assign({
1554
2483
  botToken: token,
1555
2484
  chatId,
1556
2485
  notifyTelegram: notify,
1557
2486
  onlyOnFailure: onlyFailure,
1558
2487
  kanbanBaseUrl: kanbanUrl
1559
- })
2488
+ }, delivery))
1560
2489
  });
1561
2490
  const data = await res.json();
1562
2491
  if (data && data.ok) {
@@ -1686,6 +2615,24 @@ window.__ModuleLoader__.load({
1686
2615
  React.createElement('span', null, T_KEY(t, 'settings.globalOnlyFailure'))
1687
2616
  )
1688
2617
  ),
2618
+ ...renderDeliverySettings({
2619
+ T: (key, vars) => T_KEY(t, key, vars),
2620
+ values: delivery,
2621
+ onChange: (key, value) => setDelivery((prev) => {
2622
+ const next = Object.assign({}, prev);
2623
+ if (value === undefined) delete next[key];
2624
+ else next[key] = value;
2625
+ return next;
2626
+ }),
2627
+ onChannelTemplate: (channelId, value) => setDelivery((prev) => {
2628
+ const templates = Object.assign({}, prev.channelTemplates || {});
2629
+ if (value && value.trim()) templates[channelId] = value;
2630
+ else delete templates[channelId];
2631
+ return Object.assign({}, prev, { channelTemplates: templates });
2632
+ }),
2633
+ sections,
2634
+ onToggleSection: (key) => setSections((prev) => Object.assign({}, prev, { [key]: !prev[key] })),
2635
+ }),
1689
2636
  testInfo && testInfo.message && React.createElement('div', {
1690
2637
  className: 'dsh-cron-status-banner',
1691
2638
  'data-type': testInfo.success ? 'success' : 'error',
@@ -1751,6 +2698,162 @@ window.__ModuleLoader__.load({
1751
2698
  return entry;
1752
2699
  }
1753
2700
 
2701
+ /**
2702
+ * Collapsible list of active jobs under the sidebar entry (#34).
2703
+ *
2704
+ * Vanilla DOM like the entry itself: the sidebar is not a React root here.
2705
+ * Collapsed by default, state persisted, capped at a few rows with an
2706
+ * "and N more" line so a busy schedule cannot push the rest of the sidebar
2707
+ * off screen. Clicking a row opens the panel and highlights that task.
2708
+ */
2709
+ const SIDEBAR_JOBS_KEY = 'dsh-cron-sidebar-open';
2710
+ const SIDEBAR_JOBS_MAX_ROWS = 5;
2711
+
2712
+ /** Sidebar job row: title plus next run time or the live running state. */
2713
+ function buildSidebarJobRow(state, task) {
2714
+ const row = document.createElement('button');
2715
+ row.type = 'button';
2716
+ row.className = 'dsh-cron-sidebar-job';
2717
+ // A distinct attribute: the panel uses data-task-id, and the highlight
2718
+ // lookup must never pick a sidebar row instead of a task card.
2719
+ row.setAttribute('data-sidebar-task-id', task.id);
2720
+ row.setAttribute('aria-label', task.title || task.id);
2721
+
2722
+ const title = document.createElement('span');
2723
+ title.className = 'dsh-cron-sidebar-job-title';
2724
+ title.textContent = task.title || task.id;
2725
+ row.appendChild(title);
2726
+
2727
+ const meta = document.createElement('span');
2728
+ meta.className = 'dsh-cron-sidebar-job-meta';
2729
+ if (task.running) meta.textContent = T('list.runningNow');
2730
+ else if (task.nextRunAt) meta.textContent = new Date(task.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
2731
+ else meta.textContent = task.scheduleText || task.schedule || '';
2732
+ row.appendChild(meta);
2733
+
2734
+ if (task.running) row.classList.add('dsh-cron-pulse');
2735
+ row.addEventListener('click', (event) => {
2736
+ event.preventDefault();
2737
+ event.stopPropagation();
2738
+ state.openTaskSignal(task.id);
2739
+ if (!state.toggle.isOpen()) state.toggle.toggle();
2740
+ });
2741
+ return row;
2742
+ }
2743
+
2744
+ function renderSidebarJobs(state, tasks) {
2745
+ const listEl = state.listEl;
2746
+ if (!listEl) return;
2747
+ listEl.innerHTML = '';
2748
+ if (!tasks.length) {
2749
+ const empty = document.createElement('div');
2750
+ empty.className = 'dsh-cron-sidebar-empty';
2751
+ empty.textContent = T('sidebar.noActiveJobs');
2752
+ listEl.appendChild(empty);
2753
+ return;
2754
+ }
2755
+ for (const task of tasks.slice(0, SIDEBAR_JOBS_MAX_ROWS)) listEl.appendChild(buildSidebarJobRow(state, task));
2756
+ if (tasks.length > SIDEBAR_JOBS_MAX_ROWS) {
2757
+ const more = document.createElement('div');
2758
+ more.className = 'dsh-cron-sidebar-more';
2759
+ more.textContent = T('sidebar.moreJobs', { count: tasks.length - SIDEBAR_JOBS_MAX_ROWS });
2760
+ listEl.appendChild(more);
2761
+ }
2762
+ }
2763
+
2764
+ async function loadSidebarJobs(state) {
2765
+ try {
2766
+ const res = await fetch('/dsh-cron/tasks?status=active');
2767
+ const data = await res.json();
2768
+ if (data && data.ok) renderSidebarJobs(state, data.tasks || []);
2769
+ } catch (_) {}
2770
+ }
2771
+
2772
+ function applySidebarJobsOpen(state) {
2773
+ if (!state.root) return;
2774
+ state.root.dataset.open = state.open ? 'true' : 'false';
2775
+ if (state.headEl) state.headEl.setAttribute('aria-expanded', state.open ? 'true' : 'false');
2776
+ if (state.open) loadSidebarJobs(state);
2777
+ }
2778
+
2779
+ function buildSidebarJobsRoot(state, label) {
2780
+ const root = document.createElement('div');
2781
+ root.className = 'dsh-cron-sidebar-jobs';
2782
+ root.setAttribute('data-dsh-plugin', 'dsh-cron');
2783
+ root.setAttribute('data-dsh-part', 'sidebar-jobs');
2784
+
2785
+ const headEl = document.createElement('button');
2786
+ headEl.type = 'button';
2787
+ headEl.className = 'dsh-cron-sidebar-jobs-head';
2788
+ headEl.setAttribute('aria-expanded', state.open ? 'true' : 'false');
2789
+ headEl.textContent = label;
2790
+ headEl.addEventListener('click', (event) => {
2791
+ event.preventDefault();
2792
+ event.stopPropagation();
2793
+ state.open = !state.open;
2794
+ try { window.localStorage.setItem(SIDEBAR_JOBS_KEY, state.open ? '1' : '0'); } catch (_) {}
2795
+ applySidebarJobsOpen(state);
2796
+ });
2797
+ root.appendChild(headEl);
2798
+
2799
+ const listEl = document.createElement('div');
2800
+ listEl.className = 'dsh-cron-sidebar-jobs-list';
2801
+ root.appendChild(listEl);
2802
+
2803
+ state.root = root;
2804
+ state.headEl = headEl;
2805
+ state.listEl = listEl;
2806
+ return root;
2807
+ }
2808
+
2809
+ /** Keep the section mounted under the sidebar entry across re-renders. */
2810
+ function placeSidebarJobs(state, label) {
2811
+ if (state.root !== undefined && state.root.isConnected) return;
2812
+ const entry = document.querySelector('[' + ENTRY_ATTR + ']');
2813
+ if (entry === null || entry.parentElement === null) return;
2814
+ if (state.root === undefined) {
2815
+ entry.insertAdjacentElement('afterend', buildSidebarJobsRoot(state, label));
2816
+ } else {
2817
+ entry.insertAdjacentElement('afterend', state.root);
2818
+ // Re-query the refs: the previous nodes may belong to a detached tree.
2819
+ state.root = document.querySelector('[data-dsh-part="sidebar-jobs"]') || state.root;
2820
+ state.listEl = state.root.querySelector('.dsh-cron-sidebar-jobs-list');
2821
+ state.headEl = state.root.querySelector('.dsh-cron-sidebar-jobs-head');
2822
+ }
2823
+ applySidebarJobsOpen(state);
2824
+ }
2825
+
2826
+ /**
2827
+ * Collapsible list of active jobs under the sidebar entry (#34).
2828
+ *
2829
+ * Vanilla DOM like the entry itself: the sidebar is not a React root here.
2830
+ * Collapsed by default, state persisted, capped at a few rows with an
2831
+ * "and N more" line so a busy schedule cannot push the rest of the sidebar
2832
+ * off screen. Clicking a row opens the panel and highlights that task.
2833
+ */
2834
+ function mountSidebarJobs(toggle, label, openTaskSignal) {
2835
+ if (typeof document === 'undefined') return () => {};
2836
+ const state = { toggle, openTaskSignal, root: undefined, listEl: undefined, headEl: undefined, open: false };
2837
+ try { state.open = window.localStorage.getItem(SIDEBAR_JOBS_KEY) === '1'; } catch (_) {}
2838
+
2839
+ const observer = new MutationObserver(() => placeSidebarJobs(state, label));
2840
+ observer.observe(document.body, { childList: true, subtree: true });
2841
+ placeSidebarJobs(state, label);
2842
+ loadSidebarJobs(state);
2843
+ const fetchTimer = setInterval(() => {
2844
+ let isOpen = false;
2845
+ try { isOpen = window.localStorage.getItem(SIDEBAR_JOBS_KEY) === '1'; } catch (_) {}
2846
+ if (isOpen) loadSidebarJobs(state);
2847
+ }, 30000);
2848
+
2849
+ return () => {
2850
+ observer.disconnect();
2851
+ clearInterval(fetchTimer);
2852
+ if (state.root !== undefined && state.root.isConnected) state.root.remove();
2853
+ state.root = undefined;
2854
+ };
2855
+ }
2856
+
1754
2857
  function placeEntry(target, entry) {
1755
2858
  let anchor = target;
1756
2859
  let next = target.nextElementSibling;
@@ -1933,6 +3036,7 @@ window.__ModuleLoader__.load({
1933
3036
  ctx.effect(() => {
1934
3037
  const off = [
1935
3038
  mountSidebarEntry(toggle, translate('sidebar.label')),
3039
+ mountSidebarJobs(toggle, translate('sidebar.jobsTitle'), (taskId) => { pendingTaskHighlight = taskId; }),
1936
3040
  mountCronScreen(ctx, toggle)
1937
3041
  ];
1938
3042
  return () => { for (const dispose of off) dispose(); };