@goodandready/dsh-cron 0.2.4 → 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
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',
@@ -168,7 +203,6 @@ window.__ModuleLoader__.load({
168
203
  'form.channelNtfy': 'ntfy',
169
204
  'form.channelBark': 'Bark',
170
205
  'form.channelPushplus': 'PushPlus',
171
- 'form.channelEmail': 'Email',
172
206
  'form.channelTts': 'Voice (dsh-tts)',
173
207
  'form.channelGitea': 'Gitea issue',
174
208
  'settings.deliverySection': 'Delivery channels',
@@ -188,13 +222,6 @@ window.__ModuleLoader__.load({
188
222
  'settings.barkKeyLabel': 'Bark device key',
189
223
  'settings.pushplusTokenRefLabel': 'PushPlus token credential name',
190
224
  '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
225
  'settings.ttsBaseUrlLabel': 'dsh-tts base URL',
199
226
  'settings.giteaBaseUrlLabel': 'Gitea base URL',
200
227
  'settings.giteaRepoLabel': 'Gitea repository (owner/repo)',
@@ -209,7 +236,7 @@ window.__ModuleLoader__.load({
209
236
  'settings.unavailable': 'Settings are unavailable right now.',
210
237
  'settings.retry': 'Retry',
211
238
  '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.',
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.',
213
240
  'settings.openPanel': 'Open task panel',
214
241
  'settings.modalTitle': 'Notifications & delivery channels',
215
242
  'settings.tokenLabel': 'Telegram Bot Token',
@@ -307,7 +334,7 @@ window.__ModuleLoader__.load({
307
334
  const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
308
335
 
309
336
  // 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'];
337
+ const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
311
338
  const CHANNEL_LABEL_KEYS = {
312
339
  telegram: 'form.channelTelegram',
313
340
  kanban: 'form.channelKanban',
@@ -316,7 +343,6 @@ window.__ModuleLoader__.load({
316
343
  ntfy: 'form.channelNtfy',
317
344
  bark: 'form.channelBark',
318
345
  pushplus: 'form.channelPushplus',
319
- email: 'form.channelEmail',
320
346
  tts: 'form.channelTts',
321
347
  gitea: 'form.channelGitea',
322
348
  };
@@ -327,7 +353,6 @@ window.__ModuleLoader__.load({
327
353
  { key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
328
354
  { key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
329
355
  { key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
330
- { key: 'smtpPasswordRef', labelKey: 'settings.smtpPasswordRefLabel', group: 'secrets' },
331
356
  { key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
332
357
  { key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
333
358
  { key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
@@ -336,20 +361,12 @@ window.__ModuleLoader__.load({
336
361
  { key: 'barkServerUrl', labelKey: 'settings.barkServerLabel', group: 'channels' },
337
362
  { key: 'barkKey', labelKey: 'settings.barkKeyLabel', group: 'channels' },
338
363
  { 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
364
  { key: 'ttsBaseUrl', labelKey: 'settings.ttsBaseUrlLabel', group: 'channels' },
345
365
  { key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
346
366
  { key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
347
- { key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true },
367
+ { key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true, defaultValue: 15000, min: 1000 },
348
368
  ];
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));
369
+ const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
353
370
  // The global template plus the per-channel override map (server model).
354
371
  const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
355
372
 
@@ -376,11 +393,16 @@ window.__ModuleLoader__.load({
376
393
  React.createElement('label', null, T(f.labelKey)),
377
394
  React.createElement('input', f.numeric ? {
378
395
  type: 'number',
379
- min: 1000,
396
+ min: f.min !== undefined ? f.min : 1000,
380
397
  step: 1000,
381
398
  value: val(f.key),
382
- placeholder: '15000',
383
- onChange: (e) => onChange(f.key, e.target.value === '' ? undefined : Number(e.target.value)),
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)),
384
406
  } : {
385
407
  type: 'text',
386
408
  value: val(f.key),
@@ -413,14 +435,6 @@ window.__ModuleLoader__.load({
413
435
  React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
414
436
  ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
415
437
  ),
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
- )),
424
438
  ]),
425
439
  section('templates', 'settings.templatesSection', [
426
440
  React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
@@ -523,6 +537,12 @@ window.__ModuleLoader__.load({
523
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; }
524
538
  .dsh-cron-search-input:focus { outline: none; border-color: var(--dsh-cron-info); }
525
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; }
526
546
  .dsh-cron-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
527
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; }
528
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)); }
@@ -626,6 +646,51 @@ window.__ModuleLoader__.load({
626
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); }
627
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); } }
628
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
+ }
629
694
  `;
630
695
 
631
696
  function ensureStyles() {
@@ -722,12 +787,25 @@ window.__ModuleLoader__.load({
722
787
  const t = props.t || translate;
723
788
  const [tab, setTab] = React.useState('all');
724
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);
725
796
  const [tasks, setTasks] = React.useState([]);
726
797
  const [recs, setRecs] = React.useState([]);
727
798
  const [loading, setLoading] = React.useState(false);
728
799
  const [fetchError, setFetchError] = React.useState(null);
729
800
  const [dropdownOpen, setDropdownOpen] = React.useState(false);
730
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
+
731
809
  // Manual / Edit modal state
732
810
  const [manualModalOpen, setManualModalOpen] = React.useState(false);
733
811
  const [formId, setFormId] = React.useState(null);
@@ -824,6 +902,49 @@ window.__ModuleLoader__.load({
824
902
  return () => window.removeEventListener('keydown', onKey);
825
903
  }, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
826
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
+
827
948
  const fetchTasks = async () => {
828
949
  setLoading(true);
829
950
  setFetchError(null);
@@ -868,8 +989,7 @@ window.__ModuleLoader__.load({
868
989
 
869
990
  React.useEffect(() => {
870
991
  fetchTasks();
871
- const pollInterval = setInterval(() => {
872
- // Background polling without resetting loading spinner
992
+ const pollInterval = setInterval(() => { // Background polling without resetting loading spinner
873
993
  fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query))
874
994
  .then(res => res.json())
875
995
  .then(data => {
@@ -902,13 +1022,128 @@ window.__ModuleLoader__.load({
902
1022
  }
903
1023
  };
904
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
+
905
1141
  const handleDelete = async (id) => {
906
1142
  if (!confirm(T_KEY(t, 'confirm.delete'))) return;
907
1143
  try {
908
1144
  await fetch('/dsh-cron/tasks/' + encodeURIComponent(id), { method: 'DELETE' });
909
1145
  fetchTasks();
910
- } catch (err) {}
911
- };
1146
+ } catch (err) {} };
912
1147
 
913
1148
  const openSettingsModal = async () => {
914
1149
  setTestStatus(null);
@@ -1259,7 +1494,31 @@ window.__ModuleLoader__.load({
1259
1494
  React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: openDshModal }, T_KEY(t, 'actions.createWithDsh')),
1260
1495
  React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: () => openManualModal(null) }, T_KEY(t, 'actions.setupManually'))
1261
1496
  )
1262
- )
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
+ })
1263
1522
  )
1264
1523
  ),
1265
1524
  React.createElement('div', { className: 'dsh-cron-search-bar' },
@@ -1305,6 +1564,50 @@ window.__ModuleLoader__.load({
1305
1564
  : T_KEY(t, 'tabs.completed'))
1306
1565
  )
1307
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
+ ),
1308
1611
  fetchError ? React.createElement('div', {
1309
1612
  className: 'dsh-cron-status-banner',
1310
1613
  'data-type': 'error',
@@ -1318,15 +1621,38 @@ window.__ModuleLoader__.load({
1318
1621
  onClick: () => fetchTasks()
1319
1622
  }, T_KEY(t, 'banner.retry'))
1320
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,
1321
1636
  React.createElement('div', { className: 'dsh-cron-task-list' },
1322
1637
  loading && tasks.length === 0
1323
1638
  ? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #888)', padding: '16px 0', fontSize: '14px' } }, T_KEY(t, 'list.loading'))
1324
1639
  : tasks.length === 0
1325
1640
  ? 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) =>
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) =>
1327
1652
  React.createElement('div', {
1328
1653
  key: task.id,
1329
1654
  className: 'dsh-cron-task-item',
1655
+ 'data-task-id': task.id,
1330
1656
  role: 'button',
1331
1657
  tabIndex: 0,
1332
1658
  'aria-label': task.title,
@@ -1389,6 +1715,13 @@ window.__ModuleLoader__.load({
1389
1715
  'aria-label': T_KEY(t, 'actions.edit'),
1390
1716
  onClick: () => openEditModal(task)
1391
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))),
1392
1725
  React.createElement('button', {
1393
1726
  type: 'button',
1394
1727
  className: 'dsh-cron-icon-btn dsh-cron-danger',
@@ -2003,7 +2336,50 @@ window.__ModuleLoader__.load({
2003
2336
  )
2004
2337
  ),
2005
2338
 
2006
- // 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
2007
2383
  dshModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !dshLoading && setDshModalOpen(false) },
2008
2384
  React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
2009
2385
  React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'dsh.modalTitle')),
@@ -2322,6 +2698,162 @@ window.__ModuleLoader__.load({
2322
2698
  return entry;
2323
2699
  }
2324
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
+
2325
2857
  function placeEntry(target, entry) {
2326
2858
  let anchor = target;
2327
2859
  let next = target.nextElementSibling;
@@ -2504,6 +3036,7 @@ window.__ModuleLoader__.load({
2504
3036
  ctx.effect(() => {
2505
3037
  const off = [
2506
3038
  mountSidebarEntry(toggle, translate('sidebar.label')),
3039
+ mountSidebarJobs(toggle, translate('sidebar.jobsTitle'), (taskId) => { pendingTaskHighlight = taskId; }),
2507
3040
  mountCronScreen(ctx, toggle)
2508
3041
  ];
2509
3042
  return () => { for (const dispose of off) dispose(); };