@linxin666/dsh-client-ui-task-board 0.4.3 → 0.4.4

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.
Files changed (53) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -10
  3. package/README.zh.md +17 -10
  4. package/lib/client.js +1350 -552
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +655 -134
  7. package/lib/types/client/board/TaskForm.d.ts +19 -1
  8. package/lib/types/client/board/preset-label.d.ts +22 -0
  9. package/lib/types/client/host-api.d.ts +24 -1
  10. package/lib/types/client/locales.d.ts +38 -2
  11. package/lib/types/client/schedule-zone.d.ts +47 -0
  12. package/lib/types/core/controller.d.ts +1 -0
  13. package/lib/types/core/degraded-reason.d.ts +46 -0
  14. package/lib/types/core/package-name.d.ts +9 -0
  15. package/lib/types/core/schedule.d.ts +95 -20
  16. package/lib/types/core/subtask.d.ts +30 -11
  17. package/lib/types/core/tasks.d.ts +26 -2
  18. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  19. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  20. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  21. package/lib/types/host-ledger.d.ts +56 -4
  22. package/lib/types/host-runner.d.ts +76 -3
  23. package/lib/types/host-service.d.ts +26 -0
  24. package/lib/types/protocol.d.ts +9 -3
  25. package/package.json +25 -25
  26. package/src/client/board/LinkSubtaskModal.tsx +21 -11
  27. package/src/client/board/NewTaskModal.tsx +271 -150
  28. package/src/client/board/TaskCard.tsx +24 -5
  29. package/src/client/board/TaskDetail.tsx +83 -33
  30. package/src/client/board/TaskForm.tsx +48 -3
  31. package/src/client/board/preset-label.ts +44 -0
  32. package/src/client/board.module.css +114 -5
  33. package/src/client/host-api.ts +109 -6
  34. package/src/client/index.ts +5 -1
  35. package/src/client/locales.ts +82 -8
  36. package/src/client/schedule-zone.ts +135 -0
  37. package/src/core/controller.ts +7 -2
  38. package/src/core/degraded-reason.ts +81 -0
  39. package/src/core/package-name.ts +9 -0
  40. package/src/core/schedule.ts +211 -55
  41. package/src/core/store.ts +10 -1
  42. package/src/core/subtask.ts +77 -22
  43. package/src/core/tasks.ts +38 -4
  44. package/src/core/use-cases/task-create.ts +14 -2
  45. package/src/core/use-cases/task-schedule.ts +17 -6
  46. package/src/core/use-cases/task-update.ts +4 -1
  47. package/src/host/agent-tools.ts +40 -8
  48. package/src/host-ledger.ts +203 -23
  49. package/src/host-runner.ts +164 -14
  50. package/src/host-service.ts +94 -17
  51. package/src/protocol.ts +27 -8
  52. package/lib/types/host/run-guarded.d.ts +0 -39
  53. package/src/host/run-guarded.ts +0 -76
@@ -10,8 +10,11 @@ import { effectiveTaskPermission } from '../../core/subtask.ts'
10
10
  import { collectKnownTags, TASK_PERMISSIONS, type TaskPermission, type TaskRecord, type TaskTag } from '../../core/tasks.ts'
11
11
  import { t, type TaskBoardKey } from '../locales.ts'
12
12
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
13
- import { ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx'
13
+ import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
14
+ import { CollapsibleSection, ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx'
15
+ import { formatHostTimestamp } from './TaskCard.tsx'
14
16
  import { readParseModelPreference, writeParseModelPreference } from './parse-model-pref.ts'
17
+ import { inheritPresetLabel, isBuiltinPreset, presetLabel } from './preset-label.ts'
15
18
  import css from '../board.module.css'
16
19
 
17
20
  export interface NewTaskModalProps {
@@ -51,8 +54,14 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
51
54
  const [model, setModel] = useState(initialTask?.model ?? parentTask?.model ?? '')
52
55
  const inheritedPermission = parentTask === undefined ? undefined : effectiveTaskPermission(parentTask)
53
56
  const [reuseSession, setReuseSession] = useState(initialTask?.reuseSession ?? false)
57
+ // Checked by default: a new task starts its runs with dsh's built-in /goal
58
+ // unless the user opts out here. A duplicate keeps the original card's choice.
59
+ const [goalRun, setGoalRun] = useState(initialTask?.goalRun ?? true)
54
60
  const [scheduleEnabled, setScheduleEnabled] = useState(initialTask?.schedule?.enabled ?? false)
55
61
  const [scheduleCron, setScheduleCron] = useState(initialTask?.schedule?.cron ?? '')
62
+ // '' means "follow the Host zone" (store no zone), which is the default a
63
+ // user gets unless they pick one explicitly.
64
+ const [scheduleZone, setScheduleZone] = useState(initialTask?.schedule?.timeZone ?? '')
56
65
  const [scheduleError, setScheduleError] = useState<string | undefined>(undefined)
57
66
  const [freezeText, setFreezeText] = useState('')
58
67
  const [freezeError, setFreezeError] = useState<string | undefined>(undefined)
@@ -175,8 +184,11 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
175
184
  permission: permission === '' ? undefined : permission as TaskPermission,
176
185
  model: model === '' ? undefined : model,
177
186
  ...(reuseSession ? { reuseSession: true } : {}),
187
+ ...(goalRun ? {} : { goalRun: false }),
178
188
  ...(tagList.length > 0 ? { tags: tagList } : {}),
179
- schedule: scheduleEnabled ? { enabled: true, cron: scheduleCron.trim() } : undefined,
189
+ schedule: scheduleEnabled
190
+ ? { enabled: true, cron: scheduleCron.trim(), ...(scheduleZone === '' ? {} : { timeZone: scheduleZone }) }
191
+ : undefined,
180
192
  })
181
193
  if (task === undefined) {
182
194
  setPending(false)
@@ -201,15 +213,61 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
201
213
  onClose()
202
214
  }
203
215
 
204
- /** Next-run preview for a valid armed cron (creation-time only). */
216
+ // Next-run preview for a valid armed cron (creation-time only), computed in
217
+ // the selected zone so it matches what the Host will arm.
218
+ const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
219
+ const scheduleTimeZone = scheduleZone === '' ? hostTimeZone : scheduleZone
205
220
  const scheduleNextRun = scheduleEnabled && scheduleCron.trim() !== '' && isValidCron(scheduleCron)
206
- ? nextRunAtMs(scheduleCron, Date.now())
221
+ ? nextRunAtMs(scheduleCron, Date.now(), scheduleTimeZone)
207
222
  : undefined
208
223
 
209
224
  const modalTitle = parentTask !== undefined
210
225
  ? t('new.subtaskTitle')
211
226
  : isDuplicate ? t('new.duplicateTitle') : t('board.new')
212
227
 
228
+ // Agent-preset picker rows: the four built-in presets first, then every other
229
+ // roster row (user presets). The inherit option names the preset a run
230
+ // actually lands on when the card pins nothing.
231
+ const builtinPresets = options.presets.filter(preset => isBuiltinPreset(preset.id))
232
+ const customPresets = options.presets.filter(preset => !isBuiltinPreset(preset.id))
233
+ const inheritLabel = inheritPresetLabel(options.presets)
234
+ const selectedPreset = options.presets.find(preset => preset.id === mode)
235
+ const modeLabel = mode === '' ? inheritLabel : selectedPreset === undefined ? mode : presetLabel(selectedPreset)
236
+ const workspaceLabel = workspaceId === ''
237
+ ? t('exec.workspace.recent')
238
+ : options.workspaces.find(item => item.workspaceId === workspaceId)?.title ?? workspaceId
239
+ const permissionLabel = permission === ''
240
+ ? inheritedPermission === undefined
241
+ ? t('exec.permission.default')
242
+ : t('exec.permission.inheritParent', { permission: t(`exec.permission.${inheritedPermission}` as TaskBoardKey) })
243
+ : t(`exec.permission.${permission}` as TaskBoardKey)
244
+ const modelLabel = model === ''
245
+ ? t('exec.model.default')
246
+ : (options.models ?? []).find(item => item.id === model)?.name ?? model
247
+ // Collapsed-region summaries: what each closed region currently holds, so a
248
+ // collapse never hides the configuration without a trace.
249
+ // A host-default model adds only the long parenthetical name to the
250
+ // collapsed line, so it is summarized only once it is actually pinned.
251
+ const executionSummary = [
252
+ workspaceLabel,
253
+ modeLabel,
254
+ permissionLabel,
255
+ ...(model === '' ? [] : [modelLabel]),
256
+ ].join(' · ')
257
+ const tagCount = cleanTags(tags).length
258
+ const labelsSummary = tagCount === 0 ? t('new.summary.none') : t('new.summary.labelCount', { count: String(tagCount) })
259
+ const runSummary = [
260
+ goalRun ? t('new.summary.multiRound') : t('new.summary.singleRound'),
261
+ ...(reuseSession ? [t('exec.reuseSession')] : []),
262
+ ].join(' · ')
263
+ const handoverSummary = freezeText.trim() !== '' || handoverText.trim() !== ''
264
+ ? t('new.summary.filled')
265
+ : t('new.summary.none')
266
+ const scheduleSummary = !scheduleEnabled
267
+ ? t('new.summary.scheduleOff')
268
+ : scheduleCron.trim() === '' ? t('new.summary.none') : scheduleCron.trim()
269
+ const parseSummary = parseText.trim() === '' ? t('new.summary.none') : t('new.summary.filled')
270
+
213
271
  return (
214
272
  <ModalShell
215
273
  ariaLabel={modalTitle}
@@ -222,51 +280,57 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
222
280
  secondaryAction={{ label: t('new.createAndRun'), onSubmit: () => { void submit(true) } }}
223
281
  >
224
282
  {canParse && (
225
- <section className={css.aiParse} data-dsh-part="ai-parse">
226
- <span className={css.fieldLabel}>{t('new.aiParse')}</span>
227
- <p className={css.fieldHint}>{t('new.aiParseHint')}</p>
228
- <textarea
229
- className={css.input}
230
- rows={3}
231
- value={parseText}
232
- placeholder={t('new.aiParsePlaceholder')}
233
- spellCheck={false}
234
- onChange={event => { setParseText(event.target.value); setParseError(undefined) }}
235
- />
236
- <div className={css.aiParseRow}>
237
- <select
238
- className={css.select}
239
- value={parseModel}
240
- aria-label={t('new.aiParseModel')}
241
- onChange={event => {
242
- setParseModel(event.target.value)
243
- writeParseModelPreference(event.target.value)
244
- }}
245
- >
246
- <option value="">{t('exec.model.default')}</option>
247
- {parseModels.map(option => (
248
- <option key={option.id} value={option.id}>{option.name ?? option.id}</option>
249
- ))}
250
- </select>
251
- {parsePending
252
- ? (
253
- <button type="button" className={css.ghostButton} onClick={() => { parseAbort.current?.abort() }}>
254
- {t('new.aiParseCancel')}
255
- </button>
256
- )
257
- : (
258
- <button
259
- type="button"
260
- className={css.primaryButton}
261
- disabled={parseText.trim() === ''}
262
- onClick={() => { void runParse() }}
263
- >
264
- {t('new.aiParseRun')}
265
- </button>
266
- )}
267
- </div>
268
- {parseError !== undefined && <p className={css.formError}>{parseError}</p>}
269
- </section>
283
+ <CollapsibleSection
284
+ title={t('new.section.parse')}
285
+ summary={parseSummary}
286
+ forceOpen={parseError !== undefined}
287
+ >
288
+ <section className={css.aiParse} data-dsh-part="ai-parse">
289
+ <span className={css.fieldLabel}>{t('new.aiParse')}</span>
290
+ <p className={css.fieldHint}>{t('new.aiParseHint')}</p>
291
+ <textarea
292
+ className={css.input}
293
+ rows={3}
294
+ value={parseText}
295
+ placeholder={t('new.aiParsePlaceholder')}
296
+ spellCheck={false}
297
+ onChange={event => { setParseText(event.target.value); setParseError(undefined) }}
298
+ />
299
+ <div className={css.aiParseRow}>
300
+ <select
301
+ className={css.select}
302
+ value={parseModel}
303
+ aria-label={t('new.aiParseModel')}
304
+ onChange={event => {
305
+ setParseModel(event.target.value)
306
+ writeParseModelPreference(event.target.value)
307
+ }}
308
+ >
309
+ <option value="">{t('exec.model.default')}</option>
310
+ {parseModels.map(option => (
311
+ <option key={option.id} value={option.id}>{option.name ?? option.id}</option>
312
+ ))}
313
+ </select>
314
+ {parsePending
315
+ ? (
316
+ <button type="button" className={css.ghostButton} onClick={() => { parseAbort.current?.abort() }}>
317
+ {t('new.aiParseCancel')}
318
+ </button>
319
+ )
320
+ : (
321
+ <button
322
+ type="button"
323
+ className={css.primaryButton}
324
+ disabled={parseText.trim() === ''}
325
+ onClick={() => { void runParse() }}
326
+ >
327
+ {t('new.aiParseRun')}
328
+ </button>
329
+ )}
330
+ </div>
331
+ {parseError !== undefined && <p className={css.formError}>{parseError}</p>}
332
+ </section>
333
+ </CollapsibleSection>
270
334
  )}
271
335
 
272
336
  {parentTask !== undefined && (
@@ -275,42 +339,32 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
275
339
  </p>
276
340
  )}
277
341
 
278
- <TaskContentFields
279
- title={title}
280
- description={description}
281
- prompt={prompt}
282
- onTitleChange={value => { setTitle(value); setError(undefined) }}
283
- onDescriptionChange={setDescription}
284
- onPromptChange={setPrompt}
285
- />
286
-
287
- <TaskTagFields tags={tags} knownTags={collectKnownTags(controller.getSnapshot().tasks)} onChange={setTags} />
288
-
289
- <label className={css.field}>
290
- <span className={css.fieldLabel}>{t('new.freeze')}</span>
291
- <textarea
292
- className={css.input}
293
- rows={4}
294
- value={freezeText}
295
- placeholder={t('new.freezePlaceholder')}
296
- spellCheck={false}
297
- onChange={event => { setFreezeText(event.target.value); setFreezeError(undefined) }}
298
- />
299
- </label>
300
- {freezeError !== undefined && <p className={css.formError}>{freezeError}</p>}
342
+ <CollapsibleSection title={t('new.section.content')} defaultOpen>
343
+ <TaskContentFields
344
+ title={title}
345
+ description={description}
346
+ prompt={prompt}
347
+ onTitleChange={value => { setTitle(value); setError(undefined) }}
348
+ onDescriptionChange={setDescription}
349
+ onPromptChange={setPrompt}
350
+ />
351
+ {isDuplicate && (
352
+ <label className={css.checkboxLabel}>
353
+ <input
354
+ type="checkbox"
355
+ checked={archiveOriginal}
356
+ onChange={event => { setArchiveOriginal(event.target.checked) }}
357
+ />
358
+ <span>{t('new.archiveOriginal')}</span>
359
+ </label>
360
+ )}
361
+ </CollapsibleSection>
301
362
 
302
- <label className={css.field}>
303
- <span className={css.fieldLabel}>{t('new.handover')}</span>
304
- <textarea
305
- className={css.input}
306
- rows={3}
307
- value={handoverText}
308
- placeholder={t('new.handoverPlaceholder')}
309
- spellCheck={false}
310
- onChange={event => { setHandoverText(event.target.value) }}
311
- />
312
- </label>
363
+ <CollapsibleSection title={t('new.section.labels')} summary={labelsSummary}>
364
+ <TaskTagFields tags={tags} knownTags={collectKnownTags(controller.getSnapshot().tasks)} onChange={setTags} />
365
+ </CollapsibleSection>
313
366
 
367
+ <CollapsibleSection title={t('new.section.execution')} summary={executionSummary}>
314
368
  <label className={css.field}>
315
369
  <span className={css.fieldLabel}>{t('new.workspace')}</span>
316
370
  <select
@@ -327,21 +381,36 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
327
381
  </label>
328
382
 
329
383
  <label className={css.field}>
330
- <span className={css.fieldLabel}>{t('new.mode')}</span>
384
+ <span className={css.fieldLabel}>{t('new.agentPreset')}</span>
331
385
  <select
332
386
  className={css.select}
333
387
  value={mode}
334
388
  onChange={event => { setMode(event.target.value) }}
335
389
  >
336
- <option value="">{t('exec.mode.default')}</option>
390
+ <option value="">{inheritLabel}</option>
337
391
  {!modeKnown && <option value={mode}>{mode}{t('exec.mode.removed')}</option>}
338
- {options.presets.map(preset => (
339
- <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
340
- {preset.name ?? preset.id}
341
- {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
342
- {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
343
- </option>
344
- ))}
392
+ {builtinPresets.length > 0 && (
393
+ <optgroup label={t('exec.mode.builtinGroup')}>
394
+ {builtinPresets.map(preset => (
395
+ <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
396
+ {presetLabel(preset)}
397
+ {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
398
+ {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
399
+ </option>
400
+ ))}
401
+ </optgroup>
402
+ )}
403
+ {customPresets.length > 0 && (
404
+ <optgroup label={t('exec.mode.customGroup')}>
405
+ {customPresets.map(preset => (
406
+ <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
407
+ {presetLabel(preset)}
408
+ {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
409
+ {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
410
+ </option>
411
+ ))}
412
+ </optgroup>
413
+ )}
345
414
  </select>
346
415
  </label>
347
416
 
@@ -375,7 +444,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
375
444
  ))}
376
445
  </select>
377
446
  </label>
447
+ </CollapsibleSection>
378
448
 
449
+ <CollapsibleSection title={t('new.section.run')} summary={runSummary}>
379
450
  <label className={css.scheduleToggle}>
380
451
  <input
381
452
  type="checkbox"
@@ -386,65 +457,115 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
386
457
  </label>
387
458
  <p className={css.detailText}>{t('exec.reuseSessionHint')}</p>
388
459
 
389
- <section className={css.detailSection}>
390
- <h4>{t('detail.schedule')}</h4>
391
- <label className={css.scheduleToggle}>
392
- <input
393
- type="checkbox"
394
- checked={scheduleEnabled}
395
- onChange={event => {
396
- setScheduleEnabled(event.target.checked)
397
- if (!event.target.checked) setScheduleError(undefined)
398
- }}
399
- />
400
- <span>{t('detail.schedule.enable')}</span>
401
- </label>
402
- {scheduleEnabled && (
403
- <>
404
- <div className={css.scheduleRow}>
405
- <input
406
- className={`${css.input} ${css.scheduleInput}${scheduleError !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
407
- value={scheduleCron}
408
- placeholder="0 9 * * *"
409
- spellCheck={false}
410
- aria-label={t('detail.schedule.cron')}
411
- onChange={event => { setScheduleCron(event.target.value); setScheduleError(undefined) }}
412
- />
413
- <select
414
- className={css.schedulePreset}
415
- value=""
416
- aria-label={t('detail.schedule.presets')}
417
- onChange={event => {
418
- if (event.target.value === '') return
419
- setScheduleCron(event.target.value)
420
- setScheduleError(undefined)
421
- }}
422
- >
423
- <option value="">{t('detail.schedule.presets')}…</option>
424
- {SCHEDULE_PRESETS.map(preset => (
425
- <option key={preset.cron} value={preset.cron}>{t(preset.label)}</option>
426
- ))}
427
- </select>
428
- </div>
429
- {scheduleError !== undefined && <p className={css.formError}>{scheduleError}</p>}
430
- {scheduleError === undefined && scheduleNextRun !== undefined && (
431
- <p className={css.scheduleMeta}>
432
- {t('detail.schedule.nextRun')} {new Date(scheduleNextRun).toLocaleString()}
433
- </p>
434
- )}
435
- </>
436
- )}
437
- </section>
438
- {isDuplicate && (
439
- <label className={css.checkboxLabel} style={{ marginTop: '12px' }}>
440
- <input
441
- type="checkbox"
442
- checked={archiveOriginal}
443
- onChange={event => { setArchiveOriginal(event.target.checked) }}
444
- />
445
- <span>{t('new.archiveOriginal')}</span>
446
- </label>
460
+ <label className={css.scheduleToggle}>
461
+ <input
462
+ type="checkbox"
463
+ checked={goalRun}
464
+ onChange={event => { setGoalRun(event.target.checked) }}
465
+ />
466
+ <span>{t('exec.goalRun')}</span>
467
+ </label>
468
+ <p className={css.detailText}>{t('exec.goalRunHint')}</p>
469
+ </CollapsibleSection>
470
+
471
+ <CollapsibleSection
472
+ title={t('new.section.handover')}
473
+ summary={handoverSummary}
474
+ forceOpen={freezeError !== undefined}
475
+ >
476
+ <label className={css.field}>
477
+ <span className={css.fieldLabel}>{t('new.freeze')}</span>
478
+ <textarea
479
+ className={css.input}
480
+ rows={4}
481
+ value={freezeText}
482
+ placeholder={t('new.freezePlaceholder')}
483
+ spellCheck={false}
484
+ onChange={event => { setFreezeText(event.target.value); setFreezeError(undefined) }}
485
+ />
486
+ </label>
487
+ {freezeError !== undefined && <p className={css.formError}>{freezeError}</p>}
488
+
489
+ <label className={css.field}>
490
+ <span className={css.fieldLabel}>{t('new.handover')}</span>
491
+ <textarea
492
+ className={css.input}
493
+ rows={3}
494
+ value={handoverText}
495
+ placeholder={t('new.handoverPlaceholder')}
496
+ spellCheck={false}
497
+ onChange={event => { setHandoverText(event.target.value) }}
498
+ />
499
+ </label>
500
+ </CollapsibleSection>
501
+
502
+ <CollapsibleSection
503
+ title={t('new.section.schedule')}
504
+ summary={scheduleSummary}
505
+ forceOpen={scheduleError !== undefined}
506
+ >
507
+ <label className={css.scheduleToggle}>
508
+ <input
509
+ type="checkbox"
510
+ checked={scheduleEnabled}
511
+ onChange={event => {
512
+ setScheduleEnabled(event.target.checked)
513
+ if (!event.target.checked) setScheduleError(undefined)
514
+ }}
515
+ />
516
+ <span>{t('detail.schedule.enable')}</span>
517
+ </label>
518
+ {scheduleEnabled && (
519
+ <>
520
+ <div className={css.scheduleRow}>
521
+ <input
522
+ className={`${css.input} ${css.scheduleInput}${scheduleError !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
523
+ value={scheduleCron}
524
+ placeholder="0 9 * * *"
525
+ spellCheck={false}
526
+ aria-label={t('detail.schedule.cron')}
527
+ onChange={event => { setScheduleCron(event.target.value); setScheduleError(undefined) }}
528
+ />
529
+ <select
530
+ className={css.schedulePreset}
531
+ value=""
532
+ aria-label={t('detail.schedule.presets')}
533
+ onChange={event => {
534
+ if (event.target.value === '') return
535
+ setScheduleCron(event.target.value)
536
+ setScheduleError(undefined)
537
+ }}
538
+ >
539
+ <option value="">{t('detail.schedule.presets')}…</option>
540
+ {SCHEDULE_PRESETS.map(preset => (
541
+ <option key={preset.cron} value={preset.cron}>{t(preset.label)}</option>
542
+ ))}
543
+ </select>
544
+ </div>
545
+ {scheduleError !== undefined && <p className={css.formError}>{scheduleError}</p>}
546
+ <label className={css.scheduleZone}>
547
+ <span>{t('detail.schedule.timeZone')}</span>
548
+ <select
549
+ className={css.schedulePreset}
550
+ value={scheduleZone}
551
+ aria-label={t('detail.schedule.timeZone')}
552
+ title={t('detail.schedule.timeZoneHint')}
553
+ onChange={event => { setScheduleZone(event.target.value); setScheduleError(undefined) }}
554
+ >
555
+ {zoneChoices(hostTimeZone, initialTask?.schedule?.timeZone).map(choice => (
556
+ <option key={choice.id === '' ? '__host' : choice.id} value={choice.id}>{choice.label}</option>
557
+ ))}
558
+ </select>
559
+ </label>
560
+ {scheduleError === undefined && scheduleNextRun !== undefined && (
561
+ <p className={css.scheduleMeta}>
562
+ {t('detail.schedule.nextRun')}{' '}
563
+ {nextRunLabel(scheduleNextRun, scheduleTimeZone, formatHostTimestamp, Date.now())}
564
+ </p>
565
+ )}
566
+ </>
447
567
  )}
568
+ </CollapsibleSection>
448
569
  </ModalShell>
449
570
  )
450
571
  }
@@ -13,15 +13,34 @@ import { executionLabel, tagTone } from '../../core/tasks.ts'
13
13
  import { t } from '../locales.ts'
14
14
  import css from '../board.module.css'
15
15
 
16
+ /**
17
+ * Built formatters, keyed by time zone ('' = the browser's own). Constructing
18
+ * an Intl.DateTimeFormat is orders of magnitude more expensive than formatting
19
+ * with one, and the board renders dozens of timestamps per SSE frame; the
20
+ * cached formatter keeps that cost off the render path.
21
+ */
22
+ const HOST_TIMESTAMP_FORMATS = new Map<string, Intl.DateTimeFormat>()
23
+
24
+ function hostTimestampFormat(timeZone: string | undefined): Intl.DateTimeFormat {
25
+ const key = timeZone ?? ''
26
+ const cached = HOST_TIMESTAMP_FORMATS.get(key)
27
+ if (cached !== undefined) return cached
28
+ const format = new Intl.DateTimeFormat(undefined, {
29
+ dateStyle: 'medium',
30
+ timeStyle: 'medium',
31
+ ...(timeZone === undefined ? {} : { timeZone }),
32
+ })
33
+ HOST_TIMESTAMP_FORMATS.set(key, format)
34
+ return format
35
+ }
36
+
16
37
  /** Compact relative/absolute time label. */
17
38
  export function formatHostTimestamp(ms: number, timeZone?: string): string {
18
39
  try {
19
- return new Intl.DateTimeFormat(undefined, {
20
- dateStyle: 'medium',
21
- timeStyle: 'medium',
22
- ...(timeZone === undefined ? {} : { timeZone }),
23
- }).format(new Date(ms))
40
+ return hostTimestampFormat(timeZone).format(new Date(ms))
24
41
  } catch {
42
+ // An unsupported time-zone id throws on construction: never cache it, and
43
+ // fall back to the ISO instant for this call.
25
44
  return new Date(ms).toISOString()
26
45
  }
27
46
  }