@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.
- package/README.i18n.yaml +2 -2
- package/README.md +17 -10
- package/README.zh.md +17 -10
- package/lib/client.js +1350 -552
- package/lib/client.js.map +1 -1
- package/lib/index.js +655 -134
- package/lib/types/client/board/TaskForm.d.ts +19 -1
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/locales.d.ts +38 -2
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/core/controller.d.ts +1 -0
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +26 -2
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +56 -4
- package/lib/types/host-runner.d.ts +76 -3
- package/lib/types/host-service.d.ts +26 -0
- package/lib/types/protocol.d.ts +9 -3
- package/package.json +25 -25
- package/src/client/board/LinkSubtaskModal.tsx +21 -11
- package/src/client/board/NewTaskModal.tsx +271 -150
- package/src/client/board/TaskCard.tsx +24 -5
- package/src/client/board/TaskDetail.tsx +83 -33
- package/src/client/board/TaskForm.tsx +48 -3
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board.module.css +114 -5
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +5 -1
- package/src/client/locales.ts +82 -8
- package/src/client/schedule-zone.ts +135 -0
- package/src/core/controller.ts +7 -2
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +10 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +38 -4
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host/agent-tools.ts +40 -8
- package/src/host-ledger.ts +203 -23
- package/src/host-runner.ts +164 -14
- package/src/host-service.ts +94 -17
- package/src/protocol.ts +27 -8
- package/lib/types/host/run-guarded.d.ts +0 -39
- 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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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.
|
|
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="">{
|
|
390
|
+
<option value="">{inheritLabel}</option>
|
|
337
391
|
{!modeKnown && <option value={mode}>{mode}{t('exec.mode.removed')}</option>}
|
|
338
|
-
{
|
|
339
|
-
<
|
|
340
|
-
{preset
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
<
|
|
390
|
-
<
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
checked
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
|
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
|
}
|