@goodandready/dsh-cron 0.2.4 → 0.2.6

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,10 @@ 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_TUNE = '<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"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line></svg>';
40
+ 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>';
41
+ 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
42
  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
43
 
37
44
  // Locale dictionary: English is the canonical source language (Issue #87).
@@ -40,6 +47,9 @@ window.__ModuleLoader__.load({
40
47
  const STRINGS = {
41
48
  en: {
42
49
  'sidebar.label': 'Scheduled tasks',
50
+ 'sidebar.jobsTitle': 'Active jobs',
51
+ 'sidebar.noActiveJobs': 'No active jobs',
52
+ 'sidebar.moreJobs': 'and {count} more',
43
53
  'chip.title': 'Scheduled tasks (Cron)',
44
54
  'panel.back': 'Back to chat',
45
55
  'panel.title': 'Scheduled tasks',
@@ -50,11 +60,39 @@ window.__ModuleLoader__.load({
50
60
  'actions.runNow': 'Run now',
51
61
  'actions.runNowShort': 'Run',
52
62
  'actions.edit': 'Edit / Details',
63
+ 'actions.duplicate': 'Duplicate task',
64
+ 'actions.tuneWithDsh': 'Tune with DSH',
65
+ 'tune.startFailed': 'Could not start the tuning dialogue: {error}',
53
66
  'actions.delete': 'Delete task',
67
+ 'duplicate.done': 'Copy created and paused: review its schedule before resuming it.',
68
+ 'duplicate.oneShotHint': 'The copy is one-shot and paused — its original time has likely passed, so set a new time before resuming.',
69
+ 'duplicate.failed': 'Could not duplicate the task: {error}',
70
+ 'transfer.exportTitle': 'Export tasks',
71
+ 'transfer.importTitle': 'Import tasks',
72
+ 'transfer.exported': 'Exported {count} task(s).',
73
+ 'transfer.exportFailed': 'Export failed: {error}',
74
+ 'transfer.importTitleModal': 'Import tasks',
75
+ '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.',
76
+ 'transfer.strategyAdd': 'Add as new tasks (new ids, existing ones stay untouched)',
77
+ 'transfer.strategyReplace': 'Replace tasks with the same id',
78
+ 'transfer.strategySkip': 'Skip tasks whose id already exists',
79
+ 'transfer.summary': 'This file contains {total} task(s): {add} new, {replace} matching an existing id, {skip} already skipped.',
80
+ 'transfer.confirm': 'Import',
81
+ 'transfer.imported': 'Imported {count} task(s).',
82
+ 'transfer.importFailed': 'Import failed: {error}',
83
+ 'transfer.badFile': 'The selected file is not a valid JSON export: {error}',
54
84
  'actions.pause': 'Pause',
55
85
  'actions.resume': 'Resume',
56
86
  'actions.settings': 'Notifications & channels',
57
87
  'search.placeholder': 'Search scheduled tasks',
88
+ 'filters.title': 'Filters',
89
+ 'filters.type': 'Type',
90
+ 'filters.model': 'Model',
91
+ 'filters.channel': 'Channel',
92
+ 'filters.any': 'Any',
93
+ 'filters.reset': 'Reset filters',
94
+ 'filters.empty': 'No task matches the current filters.',
95
+ 'filters.showing': 'Showing {shown} of {total}',
58
96
  'stats.activeTasks': 'Active tasks',
59
97
  'stats.totalRuns': 'Total runs',
60
98
  'stats.totalTokens': 'Tokens spent',
@@ -72,6 +110,7 @@ window.__ModuleLoader__.load({
72
110
  'list.oneShot': 'One-shot',
73
111
  'list.next': ' · next: {time}',
74
112
  'recs.title': 'Recommended tasks',
113
+ 'recs.hint': 'Ready-to-use recipes. Clicking one opens the form with its fields filled in — nothing is created until you save.',
75
114
  'modal.editTitle': 'Edit task',
76
115
  'modal.newTitle': 'New scheduled task',
77
116
  'modal.statusActive': '● Active',
@@ -135,6 +174,18 @@ window.__ModuleLoader__.load({
135
174
  'form.providerDefault': 'Default (DSH)',
136
175
  'form.modelLabel': 'LLM model',
137
176
  'form.modelDefault': 'Default',
177
+ 'form.silentRuleLabel': 'Silent rule (optional)',
178
+ 'form.silentRulePlaceholder': 'Stay silent when no filesystem is above 80%',
179
+ 'form.silentRuleHint': 'For tasks with output: a cheap model judges the run against this rule and the report is skipped when it says to stay silent. If the model is unavailable or unsure, the report is delivered.',
180
+ 'history.silentSkip': 'stayed silent:',
181
+ 'history.diagnosis': 'Diagnosis',
182
+ 'history.applySuggestion': 'Open the edit form with this suggestion',
183
+ 'form.inspectOnFailureLabel': 'Diagnose failures with a model',
184
+ 'form.inspectOnFailureHint': 'After a failed run a model reads the prompt, the error and the output, and the diagnosis with a proposed prompt change is stored with the run. Nothing is applied automatically.',
185
+ 'settings.inspectorModelLabel': 'Model for failure diagnosis',
186
+ 'form.fallbackModelLabel': 'Fallback model (optional)',
187
+ 'form.fallbackModelPlaceholder': 'e.g. deepseek-reasoner',
188
+ 'form.fallbackModelHint': 'If the run fails on the model above, it is retried once on this model. Both attempts are counted in the cost.',
138
189
  'form.promptLabelScript': 'Shell command or script',
139
190
  'form.promptLabelLlm': 'Instruction / Prompt for the agent',
140
191
  'form.promptPlaceholderScript': 'curl -fsSL https://... || exit 1',
@@ -160,7 +211,7 @@ window.__ModuleLoader__.load({
160
211
  'form.channelsHint': 'Pick the channels for this task. An explicit selection overrides the Telegram switch above; leave everything unchecked to fall back to it.',
161
212
  'form.templateLabel': 'Message template (optional)',
162
213
  'form.templatePlaceholder': '⏰ {title} — {status} in {duration}',
163
- 'form.templateHint': 'Variables: {title} {id} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}. Empty = built-in text.',
214
+ 'form.templateHint': 'Variables: {title} {id} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost} {model}. Empty = built-in text.',
164
215
  'form.channelTelegram': 'Telegram',
165
216
  'form.channelKanban': 'dsh-kanban card',
166
217
  'form.channelDiscord': 'Discord',
@@ -168,7 +219,6 @@ window.__ModuleLoader__.load({
168
219
  'form.channelNtfy': 'ntfy',
169
220
  'form.channelBark': 'Bark',
170
221
  'form.channelPushplus': 'PushPlus',
171
- 'form.channelEmail': 'Email',
172
222
  'form.channelTts': 'Voice (dsh-tts)',
173
223
  'form.channelGitea': 'Gitea issue',
174
224
  'settings.deliverySection': 'Delivery channels',
@@ -188,13 +238,6 @@ window.__ModuleLoader__.load({
188
238
  'settings.barkKeyLabel': 'Bark device key',
189
239
  'settings.pushplusTokenRefLabel': 'PushPlus token credential name',
190
240
  'settings.pushplusUrlLabel': 'PushPlus endpoint',
191
- 'settings.smtpHostLabel': 'SMTP host',
192
- 'settings.smtpPortLabel': 'SMTP port',
193
- 'settings.smtpUserLabel': 'SMTP user',
194
- 'settings.smtpPasswordRefLabel': 'SMTP password credential name',
195
- 'settings.smtpFromLabel': 'From address',
196
- 'settings.smtpToLabel': 'Recipients (comma separated)',
197
- 'settings.smtpSecureLabel': 'SMTP over TLS (implicit)',
198
241
  'settings.ttsBaseUrlLabel': 'dsh-tts base URL',
199
242
  'settings.giteaBaseUrlLabel': 'Gitea base URL',
200
243
  'settings.giteaRepoLabel': 'Gitea repository (owner/repo)',
@@ -202,6 +245,9 @@ window.__ModuleLoader__.load({
202
245
  'settings.templateLabel': 'Default message template',
203
246
  'settings.templateHint': 'Applies to every channel without its own template. Empty = built-in text.',
204
247
  'settings.deliveryTimeoutMsLabel': 'Delivery timeout per channel (ms)',
248
+ 'settings.automationSection': 'Automation models',
249
+ 'settings.automationHint': 'Models used for the small helper calls (rule evaluation, failure analysis). Leave empty to use the task model.',
250
+ 'settings.silentRuleModelLabel': 'Model for silent rules',
205
251
  'settings.secretsOnCard': 'Credentials referenced from the settings panel are shown masked and stored by name.',
206
252
  'form.nextRunLabel': 'Next run: ',
207
253
  'form.notScheduled': 'not scheduled',
@@ -209,7 +255,7 @@ window.__ModuleLoader__.load({
209
255
  'settings.unavailable': 'Settings are unavailable right now.',
210
256
  'settings.retry': 'Retry',
211
257
  'settings.cardTitle': '⏰ Task scheduler (Cron)',
212
- 'settings.cardDesc': 'Scheduled task execution (cron/intervals), background agent scenarios, and delivery of run reports to Telegram, Discord, Slack, ntfy, Bark, PushPlus, email, voice and Gitea.',
258
+ '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.',
213
259
  'settings.openPanel': 'Open task panel',
214
260
  'settings.modalTitle': 'Notifications & delivery channels',
215
261
  'settings.tokenLabel': 'Telegram Bot Token',
@@ -307,7 +353,7 @@ window.__ModuleLoader__.load({
307
353
  const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
308
354
 
309
355
  // Delivery channels (#26) — must stay in sync with lib/channels.js.
310
- const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'email', 'tts', 'gitea'];
356
+ const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
311
357
  const CHANNEL_LABEL_KEYS = {
312
358
  telegram: 'form.channelTelegram',
313
359
  kanban: 'form.channelKanban',
@@ -316,7 +362,6 @@ window.__ModuleLoader__.load({
316
362
  ntfy: 'form.channelNtfy',
317
363
  bark: 'form.channelBark',
318
364
  pushplus: 'form.channelPushplus',
319
- email: 'form.channelEmail',
320
365
  tts: 'form.channelTts',
321
366
  gitea: 'form.channelGitea',
322
367
  };
@@ -327,7 +372,6 @@ window.__ModuleLoader__.load({
327
372
  { key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
328
373
  { key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
329
374
  { key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
330
- { key: 'smtpPasswordRef', labelKey: 'settings.smtpPasswordRefLabel', group: 'secrets' },
331
375
  { key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
332
376
  { key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
333
377
  { key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
@@ -336,20 +380,14 @@ window.__ModuleLoader__.load({
336
380
  { key: 'barkServerUrl', labelKey: 'settings.barkServerLabel', group: 'channels' },
337
381
  { key: 'barkKey', labelKey: 'settings.barkKeyLabel', group: 'channels' },
338
382
  { key: 'pushplusUrl', labelKey: 'settings.pushplusUrlLabel', group: 'channels' },
339
- { key: 'smtpHost', labelKey: 'settings.smtpHostLabel', group: 'channels' },
340
- { key: 'smtpPort', labelKey: 'settings.smtpPortLabel', group: 'channels' },
341
- { key: 'smtpUser', labelKey: 'settings.smtpUserLabel', group: 'channels' },
342
- { key: 'smtpFrom', labelKey: 'settings.smtpFromLabel', group: 'channels' },
343
- { key: 'smtpTo', labelKey: 'settings.smtpToLabel', group: 'channels' },
344
383
  { key: 'ttsBaseUrl', labelKey: 'settings.ttsBaseUrlLabel', group: 'channels' },
345
384
  { key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
346
385
  { key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
347
- { key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true },
386
+ { key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true, defaultValue: 15000, min: 1000 },
387
+ { key: 'silentRuleModel', labelKey: 'settings.silentRuleModelLabel', group: 'automation' },
388
+ { key: 'inspectorModel', labelKey: 'settings.inspectorModelLabel', group: 'automation' },
348
389
  ];
349
- const DELIVERY_TOGGLE_FIELDS = [
350
- { key: 'smtpSecure', labelKey: 'settings.smtpSecureLabel' },
351
- ];
352
- const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key).concat(DELIVERY_TOGGLE_FIELDS.map((f) => f.key));
390
+ const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
353
391
  // The global template plus the per-channel override map (server model).
354
392
  const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
355
393
 
@@ -376,11 +414,16 @@ window.__ModuleLoader__.load({
376
414
  React.createElement('label', null, T(f.labelKey)),
377
415
  React.createElement('input', f.numeric ? {
378
416
  type: 'number',
379
- min: 1000,
417
+ min: f.min !== undefined ? f.min : 1000,
380
418
  step: 1000,
381
419
  value: val(f.key),
382
- placeholder: '15000',
383
- onChange: (e) => onChange(f.key, e.target.value === '' ? undefined : Number(e.target.value)),
420
+ placeholder: String(f.defaultValue !== undefined ? f.defaultValue : 15000),
421
+ // #115: clearing the field must send the explicit default. Deleting the
422
+ // key would leave the previously stored value in place (the settings
423
+ // scope skips absent keys), so the field would silently keep 1 ms.
424
+ onChange: (e) => onChange(f.key, e.target.value === ''
425
+ ? (f.defaultValue !== undefined ? f.defaultValue : 15000)
426
+ : Number(e.target.value)),
384
427
  } : {
385
428
  type: 'text',
386
429
  value: val(f.key),
@@ -413,14 +456,12 @@ window.__ModuleLoader__.load({
413
456
  React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
414
457
  ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
415
458
  ),
416
- ...DELIVERY_TOGGLE_FIELDS.map((f) => React.createElement('label', { className: 'dsh-cron-checkbox-row', key: f.key, style: { marginBottom: '10px' } },
417
- React.createElement('input', {
418
- type: 'checkbox',
419
- checked: Boolean(values[f.key]),
420
- onChange: (e) => onChange(f.key, e.target.checked)
421
- }),
422
- React.createElement('span', null, T(f.labelKey))
423
- )),
459
+ ]),
460
+ section('automation', 'settings.automationSection', [
461
+ React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.automationHint')),
462
+ React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
463
+ ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'automation').map(field)
464
+ ),
424
465
  ]),
425
466
  section('templates', 'settings.templatesSection', [
426
467
  React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
@@ -523,6 +564,12 @@ window.__ModuleLoader__.load({
523
564
  .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; }
524
565
  .dsh-cron-search-input:focus { outline: none; border-color: var(--dsh-cron-info); }
525
566
  .dsh-cron-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--dsw-alias-label-tertiary, #666); }
567
+ .dsh-cron-filters { margin-top: 8px; }
568
+ .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; }
569
+ .dsh-cron-filter-toggle:hover { color: var(--dsw-alias-label-primary, #eee); border-color: var(--dsh-cron-info); }
570
+ .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)); }
571
+ .dsh-cron-filter-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); }
572
+ .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; }
526
573
  .dsh-cron-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
527
574
  .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; }
528
575
  .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)); }
@@ -626,6 +673,51 @@ window.__ModuleLoader__.load({
626
673
  .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); }
627
674
  @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); } }
628
675
  .dsh-cron-pulse { animation: dsh-cron-pulse-ring 1.6s infinite; border-color: var(--dsh-cron-success); color: var(--dsh-cron-success); }
676
+
677
+ /* #34: sidebar job list */
678
+ .dsh-cron-sidebar-jobs { display: flex; flex-direction: column; gap: 2px; margin: 2px 0 6px 0; }
679
+ .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; }
680
+ .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)); }
681
+ .dsh-cron-sidebar-jobs-head::after { content: '▾'; font-size: 10px; transition: transform 0.15s ease; }
682
+ .dsh-cron-sidebar-jobs[data-open="false"] .dsh-cron-sidebar-jobs-head::after { transform: rotate(-90deg); }
683
+ .dsh-cron-sidebar-jobs[data-open="false"] .dsh-cron-sidebar-jobs-list { display: none; }
684
+ .dsh-cron-sidebar-jobs-list { display: flex; flex-direction: column; gap: 2px; padding-left: 10px; }
685
+ .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; }
686
+ .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); }
687
+ .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; }
688
+ .dsh-cron-sidebar-job-meta { font-size: 11px; color: var(--dsw-alias-label-tertiary, #777); }
689
+ .dsh-cron-sidebar-empty, .dsh-cron-sidebar-more { font-size: 11px; color: var(--dsw-alias-label-tertiary, #777); padding: 2px 8px; }
690
+ .dsh-cron-task-highlight { border-color: var(--dsh-cron-info) !important; box-shadow: 0 0 0 2px var(--dsh-cron-info-bg); }
691
+
692
+ /* #39: narrow screens. Only layout changes — no information is hidden. */
693
+ @media (max-width: 900px) {
694
+ .dsh-cron-container { padding: 8px 12px 24px; gap: 14px; }
695
+ .dsh-cron-form-row { grid-template-columns: 1fr; }
696
+ .dsh-cron-channel-grid { grid-template-columns: 1fr; }
697
+ .dsh-cron-filter-panel { grid-template-columns: 1fr; }
698
+ .dsh-cron-modal { padding: 16px; border-radius: 12px; }
699
+ .dsh-cron-modal-scroll { max-height: 92vh; }
700
+ .dsh-cron-task-item { flex-wrap: wrap; gap: 10px; }
701
+ .dsh-cron-task-actions { margin-left: auto; }
702
+ .dsh-cron-history-list { max-height: 320px; }
703
+ }
704
+ @media (max-width: 640px) {
705
+ .dsh-cron-container { padding: 6px 8px 20px; }
706
+ .dsh-cron-top-bar { flex-wrap: wrap; gap: 8px; }
707
+ .dsh-cron-tabs { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; }
708
+ .dsh-cron-tab { white-space: nowrap; }
709
+ .dsh-cron-stats-bar { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
710
+ .dsh-cron-task-item { align-items: flex-start; padding: 12px; }
711
+ .dsh-cron-task-title { flex-wrap: wrap; row-gap: 4px; }
712
+ .dsh-cron-modal { padding: 14px; }
713
+ .dsh-cron-modal-foot { flex-direction: column-reverse; }
714
+ .dsh-cron-modal-foot > button { width: 100%; }
715
+ .dsh-cron-filter-panel { padding: 8px 10px; }
716
+ .dsh-cron-card { padding: 14px; }
717
+ /* 16px keeps iOS Safari from zooming the page when a field is focused. */
718
+ .dsh-cron-modal input, .dsh-cron-modal select, .dsh-cron-modal textarea,
719
+ .dsh-cron-card input, .dsh-cron-search-input, .dsh-cron-filter-field select { font-size: 16px; }
720
+ }
629
721
  `;
630
722
 
631
723
  function ensureStyles() {
@@ -722,12 +814,25 @@ window.__ModuleLoader__.load({
722
814
  const t = props.t || translate;
723
815
  const [tab, setTab] = React.useState('all');
724
816
  const [query, setQuery] = React.useState('');
817
+ // Facet filters (#40): applied client-side over the already-loaded list so
818
+ // toggling them is instant and does not touch the polling request.
819
+ const [filterType, setFilterType] = React.useState('');
820
+ const [filterModel, setFilterModel] = React.useState('');
821
+ const [filterChannel, setFilterChannel] = React.useState('');
822
+ const [filtersOpen, setFiltersOpen] = React.useState(false);
725
823
  const [tasks, setTasks] = React.useState([]);
726
824
  const [recs, setRecs] = React.useState([]);
727
825
  const [loading, setLoading] = React.useState(false);
728
826
  const [fetchError, setFetchError] = React.useState(null);
729
827
  const [dropdownOpen, setDropdownOpen] = React.useState(false);
730
828
 
829
+ // Export/import state (#42)
830
+ const [transferBusy, setTransferBusy] = React.useState(false);
831
+ const [importDoc, setImportDoc] = React.useState(null);
832
+ const [importSummary, setImportSummary] = React.useState(null);
833
+ const [importStrategy, setImportStrategy] = React.useState('skip');
834
+ const importFileRef = React.useRef(null);
835
+
731
836
  // Manual / Edit modal state
732
837
  const [manualModalOpen, setManualModalOpen] = React.useState(false);
733
838
  const [formId, setFormId] = React.useState(null);
@@ -737,6 +842,9 @@ window.__ModuleLoader__.load({
737
842
  const [formPrompt, setFormPrompt] = React.useState('');
738
843
  const [formProvider, setFormProvider] = React.useState('');
739
844
  const [formModel, setFormModel] = React.useState('');
845
+ const [formFallbackModel, setFormFallbackModel] = React.useState('');
846
+ const [formSilentRule, setFormSilentRule] = React.useState('');
847
+ const [formInspectOnFailure, setFormInspectOnFailure] = React.useState(false);
740
848
  const [formDelivery, setFormDelivery] = React.useState('isolated');
741
849
  const [selectedTaskMeta, setSelectedTaskMeta] = React.useState(null);
742
850
  const [modalTab, setModalTab] = React.useState('params');
@@ -824,6 +932,49 @@ window.__ModuleLoader__.load({
824
932
  return () => window.removeEventListener('keydown', onKey);
825
933
  }, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
826
934
 
935
+ /** #40: facet filtering happens client-side over the loaded list. */
936
+ const visibleTasks = React.useMemo(() => { let list = tasks;
937
+ if (filterType) list = list.filter((x) => (x.type || 'llm') === filterType);
938
+ if (filterModel) list = list.filter((x) => String(x.model || '').split('/').pop() === filterModel);
939
+ if (filterChannel) {
940
+ list = list.filter((x) => {
941
+ const channels = Array.isArray(x.channels) && x.channels.length
942
+ ? x.channels
943
+ : [x.notifyTelegram ? 'telegram' : null, (x.kanbanMode || 'none') !== 'none' ? 'kanban' : null].filter(Boolean);
944
+ return channels.includes(filterChannel);
945
+ });
946
+ }
947
+ return list;
948
+ }, [tasks, filterType, filterModel, filterChannel]);
949
+
950
+ const filterTypeOptions = React.useMemo(
951
+ () => Array.from(new Set(tasks.map((x) => x.type || 'llm'))).sort(),
952
+ [tasks]
953
+ );
954
+
955
+ const filterModelOptions = React.useMemo(
956
+ () => Array.from(new Set(tasks.map((x) => String(x.model || '').split('/').pop()).filter(Boolean))).sort(),
957
+ [tasks]
958
+ );
959
+
960
+ const resetFilters = () => {
961
+ setFilterType('');
962
+ setFilterModel('');
963
+ setFilterChannel('');
964
+ };
965
+
966
+ /** #34: scroll to and flash the task the sidebar list asked for. */
967
+ React.useEffect(() => {
968
+ if (!pendingTaskHighlight || tasks.length === 0) return;
969
+ const id = pendingTaskHighlight;
970
+ pendingTaskHighlight = null;
971
+ const node = document.querySelector('[data-task-id="' + id + '"]');
972
+ if (!node) return;
973
+ if (typeof node.scrollIntoView === 'function') node.scrollIntoView({ block: 'center' });
974
+ node.classList.add('dsh-cron-task-highlight');
975
+ setTimeout(() => { try { node.classList.remove('dsh-cron-task-highlight'); } catch (_) {} }, 2500);
976
+ }, [tasks]);
977
+
827
978
  const fetchTasks = async () => {
828
979
  setLoading(true);
829
980
  setFetchError(null);
@@ -868,8 +1019,7 @@ window.__ModuleLoader__.load({
868
1019
 
869
1020
  React.useEffect(() => {
870
1021
  fetchTasks();
871
- const pollInterval = setInterval(() => {
872
- // Background polling without resetting loading spinner
1022
+ const pollInterval = setInterval(() => { // Background polling without resetting loading spinner
873
1023
  fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query))
874
1024
  .then(res => res.json())
875
1025
  .then(data => {
@@ -902,13 +1052,162 @@ window.__ModuleLoader__.load({
902
1052
  }
903
1053
  };
904
1054
 
1055
+ /** #41: duplicate a task; the server copies config, resets run state (#41). */
1056
+ const handleDuplicate = async (task) => {
1057
+ try {
1058
+ const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/duplicate', { method: 'POST' });
1059
+ const data = await res.json();
1060
+ if (!data.ok) {
1061
+ alert(T_KEY(t, 'duplicate.failed', { error: (data && data.error) || '' }));
1062
+ return;
1063
+ }
1064
+ await fetchTasks();
1065
+ // A one-shot copy keeps its original schedule string, so warn instead
1066
+ // of letting the user resume a task pointing at a past moment.
1067
+ alert(task.oneShot ? T_KEY(t, 'duplicate.oneShotHint') : T_KEY(t, 'duplicate.done'));
1068
+ } catch (err) {
1069
+ alert(T_KEY(t, 'duplicate.failed', { error: err.message }));
1070
+ }
1071
+ };
1072
+
1073
+ /** A task id must not shadow a collection route (#42 review finding). */
1074
+ const RESERVED_TASK_IDS = ['export', 'import'];
1075
+
1076
+ /** Import needs the script-confirm header when the file has code tasks. */
1077
+ const importHeaders = (document_) => {
1078
+ const headers = { 'Content-Type': 'application/json' };
1079
+ const tasks = (document_ && Array.isArray(document_.tasks)) ? document_.tasks : [];
1080
+ if (tasks.some((x) => CODE_FORM_TYPES.includes(x && x.type))) headers[SCRIPT_CONFIRM_HEADER] = 'script';
1081
+ return headers;
1082
+ };
1083
+
1084
+ /** #42: download the task configuration as a JSON file. */
1085
+ const handleExport = async () => {
1086
+ setTransferBusy(true);
1087
+ try {
1088
+ const res = await fetch('/dsh-cron/tasks/export');
1089
+ const data = await res.json();
1090
+ if (!data.ok) {
1091
+ alert(T_KEY(t, 'transfer.exportFailed', { error: (data && data.error) || '' }));
1092
+ return;
1093
+ }
1094
+ const blob = new Blob([JSON.stringify(data.document, null, 2)], { type: 'application/json' });
1095
+ const url = URL.createObjectURL(blob);
1096
+ const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
1097
+ const anchor = document.createElement('a');
1098
+ anchor.href = url;
1099
+ anchor.download = `dsh-cron-tasks-${stamp}.json`;
1100
+ document.body.appendChild(anchor);
1101
+ anchor.click();
1102
+ document.body.removeChild(anchor);
1103
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1104
+ alert(T_KEY(t, 'transfer.exported', { count: data.count }));
1105
+ } catch (err) {
1106
+ alert(T_KEY(t, 'transfer.exportFailed', { error: err.message }));
1107
+ } finally {
1108
+ setTransferBusy(false);
1109
+ }
1110
+ };
1111
+
1112
+ /** #42: read the picked file, validate it server-side and show the plan. */
1113
+ const handleImportFile = async (e) => {
1114
+ const file = e.target.files && e.target.files[0];
1115
+ e.target.value = '';
1116
+ if (!file) return;
1117
+ setTransferBusy(true);
1118
+ try {
1119
+ const text = await file.text();
1120
+ let document_;
1121
+ try {
1122
+ document_ = JSON.parse(text);
1123
+ } catch (parseErr) {
1124
+ alert(T_KEY(t, 'transfer.badFile', { error: parseErr.message }));
1125
+ return;
1126
+ }
1127
+ const res = await fetch('/dsh-cron/tasks/import', {
1128
+ method: 'POST',
1129
+ headers: importHeaders(document_),
1130
+ body: JSON.stringify({ document: document_, dryRun: true, strategy: importStrategy })
1131
+ });
1132
+ const data = await res.json();
1133
+ if (!data.ok) {
1134
+ alert(T_KEY(t, 'transfer.importFailed', { error: (data && data.error) || '' }));
1135
+ return;
1136
+ }
1137
+ setImportDoc(document_);
1138
+ setImportSummary(data.summary);
1139
+ } catch (err) {
1140
+ alert(T_KEY(t, 'transfer.importFailed', { error: err.message }));
1141
+ } finally {
1142
+ setTransferBusy(false);
1143
+ }
1144
+ };
1145
+
1146
+ const handleImportConfirm = async () => {
1147
+ if (!importDoc) return;
1148
+ setTransferBusy(true);
1149
+ try {
1150
+ const res = await fetch('/dsh-cron/tasks/import', {
1151
+ method: 'POST',
1152
+ headers: importHeaders(importDoc),
1153
+ body: JSON.stringify({ document: importDoc, strategy: importStrategy })
1154
+ });
1155
+ const data = await res.json();
1156
+ if (!data.ok) {
1157
+ alert(T_KEY(t, 'transfer.importFailed', { error: (data && data.error) || '' }));
1158
+ return;
1159
+ }
1160
+ setImportDoc(null);
1161
+ setImportSummary(null);
1162
+ await fetchTasks();
1163
+ alert(T_KEY(t, 'transfer.imported', { count: data.imported }));
1164
+ } catch (err) {
1165
+ alert(T_KEY(t, 'transfer.importFailed', { error: err.message }));
1166
+ } finally {
1167
+ setTransferBusy(false);
1168
+ }
1169
+ };
1170
+
1171
+ /** #49: tune an existing task in a dialogue instead of editing fields. */
1172
+ const handleTuneWithDsh = async (task) => {
1173
+ const lines = [
1174
+ `Tune the existing scheduled task "${task.title}" (id: ${task.id}).`,
1175
+ 'Its current configuration is:',
1176
+ `- type: ${task.type || 'llm'}`,
1177
+ `- schedule: ${task.scheduleText || task.schedule} (${task.schedule})`,
1178
+ `- status: ${task.status}`,
1179
+ task.model ? `- model: ${task.model}${task.fallbackModel ? `, fallback: ${task.fallbackModel}` : ''}` : null,
1180
+ Array.isArray(task.channels) && task.channels.length ? `- channels: ${task.channels.join(', ')}` : null,
1181
+ `- prompt: ${String(task.prompt || '').slice(0, 400)}`,
1182
+ '',
1183
+ 'Ask what should change, then apply it with the cron_update_task tool (read the current state again with cron_get_task if you need the full configuration).',
1184
+ 'If the change makes the task execute code (script/node/python/ssh/docker), confirm it with me explicitly before applying it.',
1185
+ ].filter(Boolean);
1186
+ try {
1187
+ const res = await fetch('/dsh-cron/chat/start', {
1188
+ method: 'POST',
1189
+ headers: { 'Content-Type': 'application/json' },
1190
+ body: JSON.stringify({ prompt: lines.join(String.fromCharCode(10)) })
1191
+ });
1192
+ const data = await res.json();
1193
+ if (!data.ok || !data.sessionId) {
1194
+ alert(T_KEY(t, 'tune.startFailed', { error: (data && data.error) || '' }));
1195
+ return;
1196
+ }
1197
+ if (onClose) onClose();
1198
+ else toggle.set(false);
1199
+ await openSession(ctx, data.sessionId);
1200
+ } catch (err) {
1201
+ alert(T_KEY(t, 'tune.startFailed', { error: err.message }));
1202
+ }
1203
+ };
1204
+
905
1205
  const handleDelete = async (id) => {
906
1206
  if (!confirm(T_KEY(t, 'confirm.delete'))) return;
907
1207
  try {
908
1208
  await fetch('/dsh-cron/tasks/' + encodeURIComponent(id), { method: 'DELETE' });
909
1209
  fetchTasks();
910
- } catch (err) {}
911
- };
1210
+ } catch (err) {} };
912
1211
 
913
1212
  const openSettingsModal = async () => {
914
1213
  setTestStatus(null);
@@ -1037,10 +1336,21 @@ window.__ModuleLoader__.load({
1037
1336
  setFormPythonPath('');
1038
1337
  setFormSkillName('');
1039
1338
  setFormWorkflowName('');
1339
+ // Reset before the preset branch, so a recipe keeps what it sets.
1340
+ setFormFallbackModel('');
1341
+ setFormSilentRule('');
1342
+ setFormInspectOnFailure(false);
1040
1343
  if (preset) {
1041
1344
  setFormTitle(preset.title || '');
1042
1345
  setFormSchedule(preset.schedule || '');
1043
1346
  setFormPrompt(preset.prompt || '');
1347
+ // #48: a recipe preset also carries its runtime and delivery shape.
1348
+ if (preset.type) setFormType(preset.type);
1349
+ if (Array.isArray(preset.channels) && preset.channels.length) setFormChannels(preset.channels.filter((c) => DELIVERY_CHANNELS.includes(c)));
1350
+ if (preset.silentRule !== undefined) setFormSilentRule(preset.silentRule || '');
1351
+ if (preset.fallbackModel !== undefined) setFormFallbackModel(preset.fallbackModel || '');
1352
+ if (preset.inspectOnFailure !== undefined) setFormInspectOnFailure(Boolean(preset.inspectOnFailure));
1353
+ setFormOnlyOnFailure(Boolean(preset.onlyOnFailure));
1044
1354
  setFormProvider(defaultModelInfo?.provider || '');
1045
1355
  setFormModel(defaultModelInfo?.model || '');
1046
1356
  if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
@@ -1070,7 +1380,7 @@ window.__ModuleLoader__.load({
1070
1380
  }
1071
1381
  };
1072
1382
 
1073
- const openEditModal = (task) => {
1383
+ const openEditModal = (task, overrides = {}) => {
1074
1384
  setDropdownOpen(false);
1075
1385
  setFormId(task.id);
1076
1386
  setSelectedTaskMeta(task);
@@ -1078,9 +1388,12 @@ window.__ModuleLoader__.load({
1078
1388
  setFormTitle(task.title || '');
1079
1389
  setFormSchedule(task.schedule || '');
1080
1390
  setFormType(task.type || 'llm');
1081
- setFormPrompt(task.prompt || '');
1391
+ setFormPrompt(overrides.prompt !== undefined ? overrides.prompt : (task.prompt || ''));
1082
1392
  setFormProvider(task.provider || '');
1083
1393
  setFormModel(task.model || '');
1394
+ setFormFallbackModel(task.fallbackModel || '');
1395
+ setFormSilentRule(task.silentRule || '');
1396
+ setFormInspectOnFailure(Boolean(task.inspectOnFailure));
1084
1397
  setFormDelivery(task.delivery || 'isolated');
1085
1398
  setFormNotifyTelegram(Boolean(task.notifyTelegram));
1086
1399
  setFormChannels(Array.isArray(task.channels) ? task.channels.filter((c) => DELIVERY_CHANNELS.includes(c)) : []);
@@ -1137,6 +1450,9 @@ window.__ModuleLoader__.load({
1137
1450
  delivery: formDelivery || 'isolated',
1138
1451
  provider: isAgentType ? (formProvider || undefined) : undefined,
1139
1452
  model: isAgentType ? (formModel || undefined) : undefined,
1453
+ fallbackModel: isAgentType ? (formFallbackModel.trim() || undefined) : undefined,
1454
+ silentRule: isAgentType ? undefined : formSilentRule.trim(),
1455
+ inspectOnFailure: isAgentType ? Boolean(formInspectOnFailure) : false,
1140
1456
  notifyTelegram: formNotifyTelegram,
1141
1457
  channels: formChannels,
1142
1458
  template: formTemplate.trim(),
@@ -1259,7 +1575,31 @@ window.__ModuleLoader__.load({
1259
1575
  React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: openDshModal }, T_KEY(t, 'actions.createWithDsh')),
1260
1576
  React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: () => openManualModal(null) }, T_KEY(t, 'actions.setupManually'))
1261
1577
  )
1262
- )
1578
+ ),
1579
+ // #42: export/import as a small pair of icon buttons.
1580
+ React.createElement('button', {
1581
+ type: 'button',
1582
+ className: 'dsh-cron-icon-btn',
1583
+ title: T_KEY(t, 'transfer.exportTitle'),
1584
+ 'aria-label': T_KEY(t, 'transfer.exportTitle'),
1585
+ disabled: transferBusy,
1586
+ onClick: handleExport
1587
+ }, React.createElement('span', svgProps(ICON_DOWNLOAD))),
1588
+ React.createElement('button', {
1589
+ type: 'button',
1590
+ className: 'dsh-cron-icon-btn',
1591
+ title: T_KEY(t, 'transfer.importTitle'),
1592
+ 'aria-label': T_KEY(t, 'transfer.importTitle'),
1593
+ disabled: transferBusy,
1594
+ onClick: () => { if (importFileRef.current) importFileRef.current.click(); }
1595
+ }, React.createElement('span', svgProps(ICON_UPLOAD))),
1596
+ React.createElement('input', {
1597
+ type: 'file',
1598
+ accept: '.json,application/json',
1599
+ ref: importFileRef,
1600
+ style: { display: 'none' },
1601
+ onChange: handleImportFile
1602
+ })
1263
1603
  )
1264
1604
  ),
1265
1605
  React.createElement('div', { className: 'dsh-cron-search-bar' },
@@ -1305,6 +1645,50 @@ window.__ModuleLoader__.load({
1305
1645
  : T_KEY(t, 'tabs.completed'))
1306
1646
  )
1307
1647
  ),
1648
+ // #40: facet filters over the loaded list.
1649
+ React.createElement('div', { className: 'dsh-cron-filters' },
1650
+ React.createElement('button', {
1651
+ type: 'button',
1652
+ className: 'dsh-cron-filter-toggle',
1653
+ 'aria-expanded': filtersOpen ? 'true' : 'false',
1654
+ onClick: () => setFiltersOpen(!filtersOpen)
1655
+ },
1656
+ T_KEY(t, 'filters.title'),
1657
+ (filterType || filterModel || filterChannel) ? ' •' : ''
1658
+ ),
1659
+ filtersOpen && React.createElement('div', { className: 'dsh-cron-filter-panel' },
1660
+ React.createElement('label', { className: 'dsh-cron-filter-field' },
1661
+ React.createElement('span', null, T_KEY(t, 'filters.type')),
1662
+ React.createElement('select', {
1663
+ value: filterType,
1664
+ onChange: (e) => setFilterType(e.target.value)
1665
+ },
1666
+ React.createElement('option', { value: '' }, T_KEY(t, 'filters.any')),
1667
+ ...filterTypeOptions.map((value) => React.createElement('option', { key: value, value }, value))
1668
+ )
1669
+ ),
1670
+ React.createElement('label', { className: 'dsh-cron-filter-field' },
1671
+ React.createElement('span', null, T_KEY(t, 'filters.model')),
1672
+ React.createElement('select', {
1673
+ value: filterModel,
1674
+ onChange: (e) => setFilterModel(e.target.value)
1675
+ },
1676
+ React.createElement('option', { value: '' }, T_KEY(t, 'filters.any')),
1677
+ ...filterModelOptions.map((value) => React.createElement('option', { key: value, value }, value))
1678
+ )
1679
+ ),
1680
+ React.createElement('label', { className: 'dsh-cron-filter-field' },
1681
+ React.createElement('span', null, T_KEY(t, 'filters.channel')),
1682
+ React.createElement('select', {
1683
+ value: filterChannel,
1684
+ onChange: (e) => setFilterChannel(e.target.value)
1685
+ },
1686
+ React.createElement('option', { value: '' }, T_KEY(t, 'filters.any')),
1687
+ ...DELIVERY_CHANNELS.map((id) => React.createElement('option', { key: id, value: id }, T_KEY(t, CHANNEL_LABEL_KEYS[id])))
1688
+ )
1689
+ )
1690
+ )
1691
+ ),
1308
1692
  fetchError ? React.createElement('div', {
1309
1693
  className: 'dsh-cron-status-banner',
1310
1694
  'data-type': 'error',
@@ -1318,15 +1702,38 @@ window.__ModuleLoader__.load({
1318
1702
  onClick: () => fetchTasks()
1319
1703
  }, T_KEY(t, 'banner.retry'))
1320
1704
  ) : null,
1705
+ (filterType || filterModel || filterChannel) ? React.createElement('div', {
1706
+ className: 'dsh-cron-filter-summary',
1707
+ style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary, #888)', marginBottom: '8px', display: 'flex', gap: '8px', alignItems: 'center' }
1708
+ },
1709
+ React.createElement('span', null, T_KEY(t, 'filters.showing', { shown: visibleTasks.length, total: tasks.length })),
1710
+ React.createElement('button', {
1711
+ type: 'button',
1712
+ className: 'dsh-cron-btn-secondary',
1713
+ style: { padding: '2px 8px', fontSize: '11px' },
1714
+ onClick: resetFilters
1715
+ }, T_KEY(t, 'filters.reset'))
1716
+ ) : null,
1321
1717
  React.createElement('div', { className: 'dsh-cron-task-list' },
1322
1718
  loading && tasks.length === 0
1323
1719
  ? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #888)', padding: '16px 0', fontSize: '14px' } }, T_KEY(t, 'list.loading'))
1324
1720
  : tasks.length === 0
1325
1721
  ? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #777)', padding: '16px 0', fontSize: '14px' } }, T_KEY(t, 'list.empty'))
1326
- : tasks.map((task) =>
1722
+ : visibleTasks.length === 0
1723
+ ? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #777)', padding: '16px 0', fontSize: '14px', display: 'flex', gap: '10px', alignItems: 'center' } },
1724
+ React.createElement('span', null, T_KEY(t, 'filters.empty')),
1725
+ React.createElement('button', {
1726
+ type: 'button',
1727
+ className: 'dsh-cron-btn-secondary',
1728
+ style: { padding: '2px 8px', fontSize: '12px' },
1729
+ onClick: resetFilters
1730
+ }, T_KEY(t, 'filters.reset'))
1731
+ )
1732
+ : visibleTasks.map((task) =>
1327
1733
  React.createElement('div', {
1328
1734
  key: task.id,
1329
1735
  className: 'dsh-cron-task-item',
1736
+ 'data-task-id': task.id,
1330
1737
  role: 'button',
1331
1738
  tabIndex: 0,
1332
1739
  'aria-label': task.title,
@@ -1389,6 +1796,20 @@ window.__ModuleLoader__.load({
1389
1796
  'aria-label': T_KEY(t, 'actions.edit'),
1390
1797
  onClick: () => openEditModal(task)
1391
1798
  }, React.createElement('span', svgProps(ICON_EDIT))),
1799
+ React.createElement('button', {
1800
+ type: 'button',
1801
+ className: 'dsh-cron-icon-btn',
1802
+ title: T_KEY(t, 'actions.tuneWithDsh'),
1803
+ 'aria-label': T_KEY(t, 'actions.tuneWithDsh'),
1804
+ onClick: () => handleTuneWithDsh(task)
1805
+ }, React.createElement('span', svgProps(ICON_TUNE))),
1806
+ React.createElement('button', {
1807
+ type: 'button',
1808
+ className: 'dsh-cron-icon-btn',
1809
+ title: T_KEY(t, 'actions.duplicate'),
1810
+ 'aria-label': T_KEY(t, 'actions.duplicate'),
1811
+ onClick: () => handleDuplicate(task)
1812
+ }, React.createElement('span', svgProps(ICON_COPY))),
1392
1813
  React.createElement('button', {
1393
1814
  type: 'button',
1394
1815
  className: 'dsh-cron-icon-btn dsh-cron-danger',
@@ -1401,6 +1822,7 @@ window.__ModuleLoader__.load({
1401
1822
  )
1402
1823
  ),
1403
1824
  React.createElement('div', { className: 'dsh-cron-recs-title' }, T_KEY(t, 'recs.title')),
1825
+ React.createElement('div', { className: 'dsh-cron-hint', style: { marginBottom: '8px' } }, T_KEY(t, 'recs.hint')),
1404
1826
  React.createElement('div', { className: 'dsh-cron-recs-list' },
1405
1827
  recs.map((rec) =>
1406
1828
  React.createElement('div', {
@@ -1487,6 +1909,35 @@ window.__ModuleLoader__.load({
1487
1909
  React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary, #888)', fontSize: '11.5px' } }, (run.durationMs || 0) + ' ms')
1488
1910
  )
1489
1911
  ),
1912
+ // #43: a diagnosed failure shows what went wrong and
1913
+ // offers to prefill the prompt with the suggestion.
1914
+ run.diagnosis && React.createElement('div', {
1915
+ className: 'dsh-cron-status-banner',
1916
+ 'data-type': 'info',
1917
+ style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '6px' }
1918
+ },
1919
+ React.createElement('div', null,
1920
+ React.createElement('strong', null, T_KEY(t, 'history.diagnosis')),
1921
+ run.confidence ? ` (${run.confidence})` : ''
1922
+ ),
1923
+ React.createElement('div', null, run.diagnosis),
1924
+ run.suggestion && React.createElement('div', null,
1925
+ React.createElement('button', {
1926
+ type: 'button',
1927
+ className: 'dsh-cron-btn-secondary',
1928
+ style: { padding: '2px 8px', fontSize: '12px' },
1929
+ onClick: () => openEditModal(selectedTaskMeta, { prompt: run.suggestion })
1930
+ }, T_KEY(t, 'history.applySuggestion'))
1931
+ )
1932
+ ),
1933
+ // #44: a run suppressed by its silent rule says why.
1934
+ run.silentSkip && React.createElement('div', {
1935
+ className: 'dsh-cron-hint',
1936
+ style: { display: 'flex', gap: '6px', alignItems: 'center' }
1937
+ },
1938
+ React.createElement('span', { className: 'dsh-cron-badge-ref' }, T_KEY(t, 'history.silentSkip')),
1939
+ React.createElement('span', null, run.silentReason || '')
1940
+ ),
1490
1941
  run.output && React.createElement('div', { className: 'dsh-cron-history-output' }, run.output),
1491
1942
  run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: 'var(--dsh-cron-danger)', borderColor: 'var(--dsh-cron-danger-bg)' } }, run.error),
1492
1943
  run.sessionId && React.createElement('button', {
@@ -1649,7 +2100,28 @@ window.__ModuleLoader__.load({
1649
2100
  React.createElement('option', { value: '' }, T_KEY(t, 'form.modelDefault')),
1650
2101
  models.map((m) => React.createElement('option', { key: m.id, value: m.id }, m.name || m.id))
1651
2102
  )
1652
- )
2103
+ ),
2104
+ // #45: one retry on a stronger model when the cheap one fails.
2105
+ React.createElement('div', { className: 'dsh-cron-form-group' },
2106
+ React.createElement('label', null, T_KEY(t, 'form.fallbackModelLabel')),
2107
+ React.createElement('input', {
2108
+ type: 'text',
2109
+ value: formFallbackModel,
2110
+ placeholder: T_KEY(t, 'form.fallbackModelPlaceholder'),
2111
+ onChange: (e) => setFormFallbackModel(e.target.value)
2112
+ }),
2113
+ React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.fallbackModelHint'))
2114
+ ),
2115
+ // #43: diagnose failures with a model.
2116
+ React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginTop: '4px' } },
2117
+ React.createElement('input', {
2118
+ type: 'checkbox',
2119
+ checked: formInspectOnFailure,
2120
+ onChange: (e) => setFormInspectOnFailure(e.target.checked)
2121
+ }),
2122
+ React.createElement('span', null, T_KEY(t, 'form.inspectOnFailureLabel'))
2123
+ ),
2124
+ formInspectOnFailure && React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.inspectOnFailureHint'))
1653
2125
  ),
1654
2126
  formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
1655
2127
  React.createElement('div', { className: 'dsh-cron-form-group' },
@@ -1871,6 +2343,17 @@ window.__ModuleLoader__.load({
1871
2343
  onChange: (e) => setFormTemplate(e.target.value)
1872
2344
  }),
1873
2345
  React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.templateHint'))
2346
+ ),
2347
+ // #44: output-producing runtimes may stay silent by rule.
2348
+ !AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-group', style: { marginTop: '12px', marginBottom: 0 } },
2349
+ React.createElement('label', null, T_KEY(t, 'form.silentRuleLabel')),
2350
+ React.createElement('input', {
2351
+ type: 'text',
2352
+ value: formSilentRule,
2353
+ placeholder: T_KEY(t, 'form.silentRulePlaceholder'),
2354
+ onChange: (e) => setFormSilentRule(e.target.value)
2355
+ }),
2356
+ React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.silentRuleHint'))
1874
2357
  )
1875
2358
  ),
1876
2359
  selectedTaskMeta && React.createElement('div', {
@@ -2003,7 +2486,50 @@ window.__ModuleLoader__.load({
2003
2486
  )
2004
2487
  ),
2005
2488
 
2006
- // 3. "Create with DSH" modal
2489
+ // 3. Import modal (#42) — summary first, then an explicit strategy.
2490
+ importDoc && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !transferBusy && setImportDoc(null) },
2491
+ React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
2492
+ React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'transfer.importTitleModal')),
2493
+ React.createElement('div', { className: 'dsh-cron-hint', style: { marginBottom: '10px' } }, T_KEY(t, 'transfer.importHint')),
2494
+ importSummary && React.createElement('div', null,
2495
+ React.createElement('div', { style: { fontSize: '13px', marginBottom: '12px' } },
2496
+ T_KEY(t, 'transfer.summary', {
2497
+ total: (importSummary.add || 0) + (importSummary.replace || 0) + (importSummary.skip || 0),
2498
+ add: importSummary.add || 0,
2499
+ replace: importSummary.replace || 0,
2500
+ skip: importSummary.skip || 0
2501
+ })
2502
+ ),
2503
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '14px' } },
2504
+ ...['add', 'replace', 'skip'].map((mode) => React.createElement('label', { key: mode, className: 'dsh-cron-checkbox-row' },
2505
+ React.createElement('input', {
2506
+ type: 'radio',
2507
+ name: 'dsh-cron-import-strategy',
2508
+ checked: importStrategy === mode,
2509
+ onChange: () => setImportStrategy(mode)
2510
+ }),
2511
+ React.createElement('span', null, T_KEY(t, mode === 'add' ? 'transfer.strategyAdd' : mode === 'replace' ? 'transfer.strategyReplace' : 'transfer.strategySkip'))
2512
+ ))
2513
+ )
2514
+ ),
2515
+ React.createElement('div', { className: 'dsh-cron-modal-foot' },
2516
+ React.createElement('button', {
2517
+ type: 'button',
2518
+ className: 'dsh-cron-btn-secondary',
2519
+ disabled: transferBusy,
2520
+ onClick: () => setImportDoc(null)
2521
+ }, T_KEY(t, 'modal.cancel')),
2522
+ React.createElement('button', {
2523
+ type: 'button',
2524
+ className: 'dsh-cron-btn-primary',
2525
+ disabled: transferBusy || !importSummary,
2526
+ onClick: handleImportConfirm
2527
+ }, T_KEY(t, 'transfer.confirm'))
2528
+ )
2529
+ )
2530
+ ),
2531
+
2532
+ // 4. "Create with DSH" modal
2007
2533
  dshModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !dshLoading && setDshModalOpen(false) },
2008
2534
  React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
2009
2535
  React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'dsh.modalTitle')),
@@ -2322,6 +2848,162 @@ window.__ModuleLoader__.load({
2322
2848
  return entry;
2323
2849
  }
2324
2850
 
2851
+ /**
2852
+ * Collapsible list of active jobs under the sidebar entry (#34).
2853
+ *
2854
+ * Vanilla DOM like the entry itself: the sidebar is not a React root here.
2855
+ * Collapsed by default, state persisted, capped at a few rows with an
2856
+ * "and N more" line so a busy schedule cannot push the rest of the sidebar
2857
+ * off screen. Clicking a row opens the panel and highlights that task.
2858
+ */
2859
+ const SIDEBAR_JOBS_KEY = 'dsh-cron-sidebar-open';
2860
+ const SIDEBAR_JOBS_MAX_ROWS = 5;
2861
+
2862
+ /** Sidebar job row: title plus next run time or the live running state. */
2863
+ function buildSidebarJobRow(state, task) {
2864
+ const row = document.createElement('button');
2865
+ row.type = 'button';
2866
+ row.className = 'dsh-cron-sidebar-job';
2867
+ // A distinct attribute: the panel uses data-task-id, and the highlight
2868
+ // lookup must never pick a sidebar row instead of a task card.
2869
+ row.setAttribute('data-sidebar-task-id', task.id);
2870
+ row.setAttribute('aria-label', task.title || task.id);
2871
+
2872
+ const title = document.createElement('span');
2873
+ title.className = 'dsh-cron-sidebar-job-title';
2874
+ title.textContent = task.title || task.id;
2875
+ row.appendChild(title);
2876
+
2877
+ const meta = document.createElement('span');
2878
+ meta.className = 'dsh-cron-sidebar-job-meta';
2879
+ if (task.running) meta.textContent = T('list.runningNow');
2880
+ else if (task.nextRunAt) meta.textContent = new Date(task.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
2881
+ else meta.textContent = task.scheduleText || task.schedule || '';
2882
+ row.appendChild(meta);
2883
+
2884
+ if (task.running) row.classList.add('dsh-cron-pulse');
2885
+ row.addEventListener('click', (event) => {
2886
+ event.preventDefault();
2887
+ event.stopPropagation();
2888
+ state.openTaskSignal(task.id);
2889
+ if (!state.toggle.isOpen()) state.toggle.toggle();
2890
+ });
2891
+ return row;
2892
+ }
2893
+
2894
+ function renderSidebarJobs(state, tasks) {
2895
+ const listEl = state.listEl;
2896
+ if (!listEl) return;
2897
+ listEl.innerHTML = '';
2898
+ if (!tasks.length) {
2899
+ const empty = document.createElement('div');
2900
+ empty.className = 'dsh-cron-sidebar-empty';
2901
+ empty.textContent = T('sidebar.noActiveJobs');
2902
+ listEl.appendChild(empty);
2903
+ return;
2904
+ }
2905
+ for (const task of tasks.slice(0, SIDEBAR_JOBS_MAX_ROWS)) listEl.appendChild(buildSidebarJobRow(state, task));
2906
+ if (tasks.length > SIDEBAR_JOBS_MAX_ROWS) {
2907
+ const more = document.createElement('div');
2908
+ more.className = 'dsh-cron-sidebar-more';
2909
+ more.textContent = T('sidebar.moreJobs', { count: tasks.length - SIDEBAR_JOBS_MAX_ROWS });
2910
+ listEl.appendChild(more);
2911
+ }
2912
+ }
2913
+
2914
+ async function loadSidebarJobs(state) {
2915
+ try {
2916
+ const res = await fetch('/dsh-cron/tasks?status=active');
2917
+ const data = await res.json();
2918
+ if (data && data.ok) renderSidebarJobs(state, data.tasks || []);
2919
+ } catch (_) {}
2920
+ }
2921
+
2922
+ function applySidebarJobsOpen(state) {
2923
+ if (!state.root) return;
2924
+ state.root.dataset.open = state.open ? 'true' : 'false';
2925
+ if (state.headEl) state.headEl.setAttribute('aria-expanded', state.open ? 'true' : 'false');
2926
+ if (state.open) loadSidebarJobs(state);
2927
+ }
2928
+
2929
+ function buildSidebarJobsRoot(state, label) {
2930
+ const root = document.createElement('div');
2931
+ root.className = 'dsh-cron-sidebar-jobs';
2932
+ root.setAttribute('data-dsh-plugin', 'dsh-cron');
2933
+ root.setAttribute('data-dsh-part', 'sidebar-jobs');
2934
+
2935
+ const headEl = document.createElement('button');
2936
+ headEl.type = 'button';
2937
+ headEl.className = 'dsh-cron-sidebar-jobs-head';
2938
+ headEl.setAttribute('aria-expanded', state.open ? 'true' : 'false');
2939
+ headEl.textContent = label;
2940
+ headEl.addEventListener('click', (event) => {
2941
+ event.preventDefault();
2942
+ event.stopPropagation();
2943
+ state.open = !state.open;
2944
+ try { window.localStorage.setItem(SIDEBAR_JOBS_KEY, state.open ? '1' : '0'); } catch (_) {}
2945
+ applySidebarJobsOpen(state);
2946
+ });
2947
+ root.appendChild(headEl);
2948
+
2949
+ const listEl = document.createElement('div');
2950
+ listEl.className = 'dsh-cron-sidebar-jobs-list';
2951
+ root.appendChild(listEl);
2952
+
2953
+ state.root = root;
2954
+ state.headEl = headEl;
2955
+ state.listEl = listEl;
2956
+ return root;
2957
+ }
2958
+
2959
+ /** Keep the section mounted under the sidebar entry across re-renders. */
2960
+ function placeSidebarJobs(state, label) {
2961
+ if (state.root !== undefined && state.root.isConnected) return;
2962
+ const entry = document.querySelector('[' + ENTRY_ATTR + ']');
2963
+ if (entry === null || entry.parentElement === null) return;
2964
+ if (state.root === undefined) {
2965
+ entry.insertAdjacentElement('afterend', buildSidebarJobsRoot(state, label));
2966
+ } else {
2967
+ entry.insertAdjacentElement('afterend', state.root);
2968
+ // Re-query the refs: the previous nodes may belong to a detached tree.
2969
+ state.root = document.querySelector('[data-dsh-part="sidebar-jobs"]') || state.root;
2970
+ state.listEl = state.root.querySelector('.dsh-cron-sidebar-jobs-list');
2971
+ state.headEl = state.root.querySelector('.dsh-cron-sidebar-jobs-head');
2972
+ }
2973
+ applySidebarJobsOpen(state);
2974
+ }
2975
+
2976
+ /**
2977
+ * Collapsible list of active jobs under the sidebar entry (#34).
2978
+ *
2979
+ * Vanilla DOM like the entry itself: the sidebar is not a React root here.
2980
+ * Collapsed by default, state persisted, capped at a few rows with an
2981
+ * "and N more" line so a busy schedule cannot push the rest of the sidebar
2982
+ * off screen. Clicking a row opens the panel and highlights that task.
2983
+ */
2984
+ function mountSidebarJobs(toggle, label, openTaskSignal) {
2985
+ if (typeof document === 'undefined') return () => {};
2986
+ const state = { toggle, openTaskSignal, root: undefined, listEl: undefined, headEl: undefined, open: false };
2987
+ try { state.open = window.localStorage.getItem(SIDEBAR_JOBS_KEY) === '1'; } catch (_) {}
2988
+
2989
+ const observer = new MutationObserver(() => placeSidebarJobs(state, label));
2990
+ observer.observe(document.body, { childList: true, subtree: true });
2991
+ placeSidebarJobs(state, label);
2992
+ loadSidebarJobs(state);
2993
+ const fetchTimer = setInterval(() => {
2994
+ let isOpen = false;
2995
+ try { isOpen = window.localStorage.getItem(SIDEBAR_JOBS_KEY) === '1'; } catch (_) {}
2996
+ if (isOpen) loadSidebarJobs(state);
2997
+ }, 30000);
2998
+
2999
+ return () => {
3000
+ observer.disconnect();
3001
+ clearInterval(fetchTimer);
3002
+ if (state.root !== undefined && state.root.isConnected) state.root.remove();
3003
+ state.root = undefined;
3004
+ };
3005
+ }
3006
+
2325
3007
  function placeEntry(target, entry) {
2326
3008
  let anchor = target;
2327
3009
  let next = target.nextElementSibling;
@@ -2504,6 +3186,7 @@ window.__ModuleLoader__.load({
2504
3186
  ctx.effect(() => {
2505
3187
  const off = [
2506
3188
  mountSidebarEntry(toggle, translate('sidebar.label')),
3189
+ mountSidebarJobs(toggle, translate('sidebar.jobsTitle'), (taskId) => { pendingTaskHighlight = taskId; }),
2507
3190
  mountCronScreen(ctx, toggle)
2508
3191
  ];
2509
3192
  return () => { for (const dispose of off) dispose(); };