@linxin666/dsh-client-ui-task-board 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +20 -12
  3. package/README.zh.md +20 -12
  4. package/lib/client.js +1327 -229
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1909 -348
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +12 -0
  8. package/lib/types/client/index.d.ts +4 -5
  9. package/lib/types/client/locales.d.ts +65 -0
  10. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  11. package/lib/types/client/schedule-zone.d.ts +50 -0
  12. package/lib/types/core/controller.d.ts +10 -11
  13. package/lib/types/core/poll-cadence.d.ts +31 -0
  14. package/lib/types/core/tasks.d.ts +144 -14
  15. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  16. package/lib/types/core/verification-budget.d.ts +52 -0
  17. package/lib/types/core/verification.d.ts +376 -9
  18. package/lib/types/host/verification-gate.d.ts +24 -0
  19. package/lib/types/host/verification-runner.d.ts +38 -7
  20. package/lib/types/host-ledger.d.ts +82 -7
  21. package/lib/types/host-runner.d.ts +32 -9
  22. package/lib/types/host-service.d.ts +117 -9
  23. package/lib/types/index.d.ts +54 -0
  24. package/lib/types/protocol.d.ts +50 -12
  25. package/package.json +2 -6
  26. package/src/client/TaskBoardSettingsCard.tsx +74 -1
  27. package/src/client/board/NewTaskModal.tsx +151 -20
  28. package/src/client/board/TaskCard.tsx +3 -3
  29. package/src/client/board/TaskDetail.tsx +219 -18
  30. package/src/client/board/VerificationReport.tsx +132 -13
  31. package/src/client/board/status-key.ts +3 -0
  32. package/src/client/index.ts +43 -50
  33. package/src/client/locales.ts +137 -4
  34. package/src/client/plugin-card-seat.ts +7 -0
  35. package/src/client/schedule-zone.ts +100 -2
  36. package/src/core/controller.ts +12 -8
  37. package/src/core/poll-cadence.ts +41 -0
  38. package/src/core/store.ts +64 -15
  39. package/src/core/tasks.ts +193 -39
  40. package/src/core/use-cases/task-create.ts +30 -9
  41. package/src/core/use-cases/task-schedule.ts +190 -25
  42. package/src/core/use-cases/task-update.ts +3 -3
  43. package/src/core/verification-budget.ts +115 -0
  44. package/src/core/verification.ts +785 -17
  45. package/src/host/agent-tools.ts +106 -21
  46. package/src/host/verification-gate.ts +172 -16
  47. package/src/host/verification-runner.ts +207 -40
  48. package/src/host-ledger.ts +328 -42
  49. package/src/host-runner.ts +52 -19
  50. package/src/host-service.ts +299 -36
  51. package/src/index.ts +101 -5
  52. package/src/protocol.ts +132 -28
  53. package/lib/invariant.js +0 -6
  54. package/lib/types/client/board-card-seat.d.ts +0 -34
  55. package/lib/types/invariant.d.ts +0 -3
  56. package/src/client/board-card-seat.ts +0 -102
  57. package/src/invariant.ts +0 -4
@@ -12,8 +12,9 @@ import type { TaskBoardPowerSnapshot, TaskBoardVerificationOptions } from '../pr
12
12
  import type { TaskBoardExtensionDispatch } from '../core/extension.ts'
13
13
  import { resolveContract, type ModelCatalogView, type VerificationSettings } from '../core/verification.ts'
14
14
  import { parseModelRoute } from '../core/verification.ts'
15
- import { PluginSettingsCard, BooleanField, ChoiceField } from './PluginSettingsCard.tsx'
15
+ import { PluginSettingsCard, BooleanField, ChoiceField, ValueField } from './PluginSettingsCard.tsx'
16
16
  import { SUBTASK_DEPTH_MAX, SUBTASK_DEPTH_MIN } from '../core/subtask.ts'
17
+ import { SESSION_POLL_MAX_SECONDS, SESSION_POLL_MIN_SECONDS } from '../core/poll-cadence.ts'
17
18
  import { CardForm, booleanField, type CardActions, type CardShell, type FieldSpec, type FieldState as CardFieldState } from './settings-form.ts'
18
19
  import settingsCss from './board-settings.module.css'
19
20
 
@@ -23,6 +24,28 @@ const SUBTASK_DEPTH_CHOICES: readonly string[] = Array.from(
23
24
  (_, index) => String(SUBTASK_DEPTH_MIN + index),
24
25
  )
25
26
 
27
+ /**
28
+ * The roster-poll cadence field: a whole number of seconds inside the
29
+ * supported range, because the Host schema (`sessionPollSeconds`) is numeric.
30
+ * A draft outside the range blocks the save instead of staging a value the
31
+ * Host would clamp behind the user's back.
32
+ */
33
+ function sessionPollField(): FieldSpec {
34
+ return {
35
+ field: 'sessionPollSeconds',
36
+ format: value => typeof value === 'number' && Number.isInteger(value) ? String(value) : '',
37
+ parse: (text) => {
38
+ const trimmed = text.trim()
39
+ if (trimmed === '') return { kind: 'clear' }
40
+ if (!/^\d+$/.test(trimmed)) return undefined
41
+ const seconds = Number(trimmed)
42
+ return seconds >= SESSION_POLL_MIN_SECONDS && seconds <= SESSION_POLL_MAX_SECONDS
43
+ ? { kind: 'set', value: seconds }
44
+ : undefined
45
+ },
46
+ }
47
+ }
48
+
26
49
  /**
27
50
  * The depth field: a choice among the supported levels whose draft text is a
28
51
  * number, because the Host schema (`maxSubtaskDepth`) is numeric. A draft
@@ -84,6 +107,14 @@ export interface TaskBoardSettings {
84
107
  preventIdleSleep?: boolean
85
108
  /** Subtask depth limit (1..3); 1 means a single level of subtasks. */
86
109
  maxSubtaskDepth?: number
110
+ /** Roster-poll cadence in seconds while the board has work to reconcile. */
111
+ sessionPollSeconds?: number
112
+ /**
113
+ * GLOBAL native /goal switch (default off): the master control over whether
114
+ * this board starts a run with dsh's built-in /goal at all. Frozen per
115
+ * execution.
116
+ */
117
+ goalRunEnabled?: boolean
87
118
  /** Goal acceptance for this board's executions (default on). */
88
119
  goalVerification?: boolean
89
120
  /** Judge model route for goal acceptance; blank inherits the host default. */
@@ -102,6 +133,10 @@ export interface TaskBoardSettingsCardState extends CardShell {
102
133
  preventIdleSleep: CardFieldState
103
134
  /** Subtask depth limit field. */
104
135
  maxSubtaskDepth: CardFieldState
136
+ /** Roster-poll cadence field. */
137
+ sessionPollSeconds: CardFieldState
138
+ /** GLOBAL native /goal switch. */
139
+ goalRunEnabled: CardFieldState
105
140
  /** Goal-acceptance switch. */
106
141
  goalVerification: CardFieldState
107
142
  /** Judge model route field. */
@@ -132,6 +167,8 @@ export class TaskBoardSettingsCardController {
132
167
  booleanField('announceToAgent'),
133
168
  booleanField('preventIdleSleep'),
134
169
  subtaskDepthField(),
170
+ sessionPollField(),
171
+ booleanField('goalRunEnabled'),
135
172
  booleanField('goalVerification'),
136
173
  judgeModelField(),
137
174
  judgeEffortField(),
@@ -146,6 +183,8 @@ export class TaskBoardSettingsCardController {
146
183
  announceToAgent: this.form.field('announceToAgent'),
147
184
  preventIdleSleep: this.form.field('preventIdleSleep'),
148
185
  maxSubtaskDepth: this.form.field('maxSubtaskDepth'),
186
+ sessionPollSeconds: this.form.field('sessionPollSeconds'),
187
+ goalRunEnabled: this.form.field('goalRunEnabled'),
149
188
  goalVerification: this.form.field('goalVerification'),
150
189
  goalVerificationModel: this.form.field('goalVerificationModel'),
151
190
  goalVerificationReasoningEffort: this.form.field('goalVerificationReasoningEffort'),
@@ -342,6 +381,16 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
342
381
  onEdit={(text) => { props.edit('preventIdleSleep', text) }}
343
382
  onReset={() => { props.resetField('preventIdleSleep') }}
344
383
  />
384
+ <ValueField
385
+ id="settings-task-board-session-poll"
386
+ label={t('settings.sessionPollSeconds')}
387
+ hint={t('settings.sessionPollSecondsHint')}
388
+ numeric
389
+ {...fieldProps}
390
+ {...state.sessionPollSeconds}
391
+ onEdit={(text) => { props.edit('sessionPollSeconds', text) }}
392
+ onReset={() => { props.resetField('sessionPollSeconds') }}
393
+ />
345
394
  <ChoiceField
346
395
  id="settings-task-board-subtask-depth"
347
396
  label={t('settings.maxSubtaskDepth')}
@@ -358,6 +407,30 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
358
407
  />
359
408
  </PluginSettingsCard>
360
409
 
410
+ <PluginSettingsCard
411
+ t={t}
412
+ titleKey="settings.runBehaviorTitle"
413
+ descriptionKey="settings.runBehaviorCardHint"
414
+ defaultOpen={false}
415
+ hideFooter
416
+ state={nestedShell}
417
+ onSave={props.save}
418
+ onDiscard={props.discard}
419
+ >
420
+ <BooleanField
421
+ id="settings-task-board-goal-run"
422
+ label={t('settings.goalRunGlobal')}
423
+ hint={t('settings.goalRunGlobalHint')}
424
+ inheritLabel={t('settings.inherit')}
425
+ onLabel={t('settings.on')}
426
+ offLabel={t('settings.off')}
427
+ {...fieldProps}
428
+ {...state.goalRunEnabled}
429
+ onEdit={(text) => { props.edit('goalRunEnabled', text) }}
430
+ onReset={() => { props.resetField('goalRunEnabled') }}
431
+ />
432
+ </PluginSettingsCard>
433
+
361
434
  <PluginSettingsCard
362
435
  t={t}
363
436
  titleKey="settings.goalVerificationTitle"
@@ -7,10 +7,10 @@ import type { BoardController } from '../../core/controller.ts'
7
7
  import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
8
8
  import { parseFreezeRequest } from '../../core/freeze-snapshot.ts'
9
9
  import { effectiveTaskPermission } from '../../core/subtask.ts'
10
- import { collectKnownTags, TASK_PERMISSIONS, type TaskPermission, type TaskRecord, type TaskTag } from '../../core/tasks.ts'
10
+ import { collectKnownTags, TASK_PERMISSIONS, type ScheduleMode, 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 { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
13
+ import { defaultZonedInputValue, nextRunLabel, parseZonedInput, runCapOption, zonedInputValue, zoneChoices } from '../schedule-zone.ts'
14
14
  import { CollapsibleSection, ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx'
15
15
  import type { OverlayPhase } from './overlay.tsx'
16
16
  import { formatHostTimestamp } from './TaskCard.tsx'
@@ -57,12 +57,20 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
57
57
  const [model, setModel] = useState(initialTask?.model ?? parentTask?.model ?? '')
58
58
  const inheritedPermission = parentTask === undefined ? undefined : effectiveTaskPermission(parentTask)
59
59
  const [reuseSession, setReuseSession] = useState(initialTask?.reuseSession ?? false)
60
- // Checked by default: a new task starts its runs with dsh's built-in /goal
61
- // unless the user opts out here. A duplicate keeps the original card's choice.
62
- const [goalRun, setGoalRun] = useState(initialTask?.goalRun ?? true)
60
+ // Unchecked by default: a new task runs one plain turn unless the user opts
61
+ // into dsh's built-in /goal here. A duplicate keeps the original card's choice.
62
+ const [goalRun, setGoalRun] = useState(initialTask?.goalRun ?? false)
63
63
  const [skipVerification, setSkipVerification] = useState(initialTask?.skipVerification ?? false)
64
64
  const [scheduleEnabled, setScheduleEnabled] = useState(initialTask?.schedule?.enabled ?? false)
65
+ const [scheduleMode, setScheduleMode] = useState<ScheduleMode>(initialTask?.schedule?.mode ?? 'cron')
65
66
  const [scheduleCron, setScheduleCron] = useState(initialTask?.schedule?.cron ?? '')
67
+ // A duplicate keeps the original card's plan; a fresh one-shot defaults to
68
+ // the next hour, so the field never opens on a past instant.
69
+ const [scheduleAt, setScheduleAt] = useState<string>(() => initialTask?.schedule?.mode === 'once' && initialTask.schedule.at !== undefined
70
+ ? zonedInputValue(initialTask.schedule.at, initialTask.schedule.timeZone)
71
+ : defaultZonedInputValue(Date.now(), initialTask?.schedule?.timeZone))
72
+ const [scheduleMaxRunsChoice, setScheduleMaxRunsChoice] = useState<string>(() => runCapOption(initialTask?.schedule?.maxRuns))
73
+ const [scheduleMaxRunsText, setScheduleMaxRunsText] = useState<string>(initialTask?.schedule?.maxRuns === undefined ? '' : String(initialTask.schedule.maxRuns))
66
74
  // '' means "follow the Host zone" (store no zone), which is the default a
67
75
  // user gets unless they pick one explicitly.
68
76
  const [scheduleZone, setScheduleZone] = useState(initialTask?.schedule?.timeZone ?? '')
@@ -77,6 +85,11 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
77
85
  const [error, setError] = useState<string | undefined>(undefined)
78
86
  const [pending, setPending] = useState(false)
79
87
  const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
88
+ // Live GLOBAL native-/goal switch. The new-task form never rewrites a stored
89
+ // preference from it: the checkbox stays operable and is explained while the
90
+ // master switch is off, and the user's own choice is what a later re-enable
91
+ // restores.
92
+ const [goalRunEnabled, setGoalRunEnabled] = useState(controller.getSnapshot().host?.goalRunEnabled === true)
80
93
  // "Parse pasted text" (issue #1540) exists only when the deployment carries a
81
94
  // parse face; the section stays hidden otherwise.
82
95
  const [canParse] = useState(controller.getSnapshot().canParseTask === true)
@@ -99,7 +112,11 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
99
112
  // The workspace list and preset roster arrive from the runtime after mount;
100
113
  // follow them so the pickers never freeze on an empty snapshot.
101
114
  useEffect(
102
- () => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)),
115
+ () => controller.subscribe(() => {
116
+ const snapshot = controller.getSnapshot()
117
+ setOptions(snapshot.executionOptions)
118
+ setGoalRunEnabled(snapshot.host?.goalRunEnabled === true)
119
+ }),
103
120
  [controller],
104
121
  )
105
122
 
@@ -145,10 +162,25 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
145
162
  */
146
163
  const submit = async (runAfterCreate: boolean): Promise<void> => {
147
164
  if (scheduleEnabled) {
148
- const cron = scheduleCron.trim()
149
- if (cron === '' || !isValidCron(cron)) {
150
- setScheduleError(t('detail.schedule.invalid'))
151
- return
165
+ if (scheduleMode === 'once') {
166
+ const at = parseZonedInput(scheduleAt, scheduleZone === '' ? hostTimeZone : scheduleZone)
167
+ if (at === undefined || at <= Date.now()) {
168
+ setScheduleError(t('detail.schedule.atInvalid'))
169
+ return
170
+ }
171
+ } else {
172
+ const cron = scheduleCron.trim()
173
+ if (cron === '' || !isValidCron(cron)) {
174
+ setScheduleError(t('detail.schedule.invalid'))
175
+ return
176
+ }
177
+ if (scheduleMaxRunsChoice === 'custom') {
178
+ const cap = Number(scheduleMaxRunsText.trim())
179
+ if (scheduleMaxRunsText.trim() === '' || !Number.isInteger(cap) || cap < 1) {
180
+ setScheduleError(t('detail.schedule.maxRunsInvalid'))
181
+ return
182
+ }
183
+ }
152
184
  }
153
185
  }
154
186
  // Optional continuation-card snapshot: parse the freeze block through the
@@ -188,11 +220,26 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
188
220
  permission: permission === '' ? undefined : permission as TaskPermission,
189
221
  model: model === '' ? undefined : model,
190
222
  ...(reuseSession ? { reuseSession: true } : {}),
191
- ...(goalRun ? {} : { goalRun: false }),
223
+ ...(goalRun ? { goalRun: true } : {}),
192
224
  ...(skipVerification ? { skipVerification: true } : {}),
193
225
  ...(tagList.length > 0 ? { tags: tagList } : {}),
194
226
  schedule: scheduleEnabled
195
- ? { enabled: true, cron: scheduleCron.trim(), ...(scheduleZone === '' ? {} : { timeZone: scheduleZone }) }
227
+ ? scheduleMode === 'once'
228
+ ? {
229
+ enabled: true,
230
+ mode: 'once' as const,
231
+ at: parseZonedInput(scheduleAt, scheduleZone === '' ? hostTimeZone : scheduleZone)!,
232
+ ...(scheduleZone === '' ? {} : { timeZone: scheduleZone }),
233
+ }
234
+ : {
235
+ enabled: true,
236
+ mode: 'cron' as const,
237
+ cron: scheduleCron.trim(),
238
+ ...(scheduleMaxRunsChoice === 'unlimited' ? {} : {
239
+ maxRuns: scheduleMaxRunsChoice === 'custom' ? Number(scheduleMaxRunsText.trim()) : Number(scheduleMaxRunsChoice),
240
+ }),
241
+ ...(scheduleZone === '' ? {} : { timeZone: scheduleZone }),
242
+ }
196
243
  : undefined,
197
244
  })
198
245
  if (task === undefined) {
@@ -218,13 +265,18 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
218
265
  onClose()
219
266
  }
220
267
 
221
- // Next-run preview for a valid armed cron (creation-time only), computed in
222
- // the selected zone so it matches what the Host will arm.
268
+ // Next-run preview for the armed plan (creation-time only), computed in the
269
+ // selected zone so it matches what the Host will arm. A one-shot's next run
270
+ // IS its planned instant; a recurring rule resolves its next occurrence.
223
271
  const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
224
272
  const scheduleTimeZone = scheduleZone === '' ? hostTimeZone : scheduleZone
225
- const scheduleNextRun = scheduleEnabled && scheduleCron.trim() !== '' && isValidCron(scheduleCron)
226
- ? nextRunAtMs(scheduleCron, Date.now(), scheduleTimeZone)
227
- : undefined
273
+ const scheduleNextRun = !scheduleEnabled
274
+ ? undefined
275
+ : scheduleMode === 'once'
276
+ ? parseZonedInput(scheduleAt, scheduleTimeZone)
277
+ : scheduleCron.trim() !== '' && isValidCron(scheduleCron)
278
+ ? nextRunAtMs(scheduleCron, Date.now(), scheduleTimeZone)
279
+ : undefined
228
280
 
229
281
  const modalTitle = parentTask !== undefined
230
282
  ? t('new.subtaskTitle')
@@ -262,7 +314,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
262
314
  const tagCount = cleanTags(tags).length
263
315
  const labelsSummary = tagCount === 0 ? t('new.summary.none') : t('new.summary.labelCount', { count: String(tagCount) })
264
316
  const runSummary = [
265
- goalRun ? t('new.summary.multiRound') : t('new.summary.singleRound'),
317
+ // A card whose goal option is on but whose master switch is off will run one
318
+ // plain turn: the collapsed line says the truth rather than the stored value.
319
+ goalRun && goalRunEnabled ? t('new.summary.multiRound') : t('new.summary.singleRound'),
266
320
  ...(reuseSession ? [t('exec.reuseSession')] : []),
267
321
  ].join(' · ')
268
322
  const handoverSummary = freezeText.trim() !== '' || handoverText.trim() !== ''
@@ -270,7 +324,18 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
270
324
  : t('new.summary.none')
271
325
  const scheduleSummary = !scheduleEnabled
272
326
  ? t('new.summary.scheduleOff')
273
- : scheduleCron.trim() === '' ? t('new.summary.none') : scheduleCron.trim()
327
+ : scheduleMode === 'once'
328
+ ? `${t('detail.schedule.mode.once')} · ${scheduleAt.replace('T', ' ')}`
329
+ : scheduleCron.trim() === ''
330
+ ? t('new.summary.none')
331
+ : [
332
+ scheduleCron.trim(),
333
+ ...(scheduleMaxRunsChoice === 'unlimited'
334
+ ? []
335
+ : [t('detail.schedule.maxRunsValue', {
336
+ count: scheduleMaxRunsChoice === 'custom' ? scheduleMaxRunsText : scheduleMaxRunsChoice,
337
+ })]),
338
+ ].join(' · ')
274
339
  const parseSummary = parseText.trim() === '' ? t('new.summary.none') : t('new.summary.filled')
275
340
 
276
341
  return (
@@ -467,11 +532,15 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
467
532
  <input
468
533
  type="checkbox"
469
534
  checked={goalRun}
535
+ // Always operable: the global switch decides whether the choice
536
+ // takes effect, never whether it may be stated, and the state (and a
537
+ // duplicate's template) is never rewritten by it.
470
538
  onChange={event => { setGoalRun(event.target.checked) }}
471
539
  />
472
540
  <span>{t('exec.goalRun')}</span>
473
541
  </label>
474
542
  <p className={css.detailText}>{t('exec.goalRunHint')}</p>
543
+ {!goalRunEnabled && <p className={css.detailText}>{t('settings.goalRunGlobalDisabledTaskOption')}</p>}
475
544
 
476
545
  <label className={css.scheduleToggle}>
477
546
  <input
@@ -533,6 +602,32 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
533
602
  </label>
534
603
  {scheduleEnabled && (
535
604
  <>
605
+ <label className={css.scheduleZone}>
606
+ <span>{t('detail.schedule.mode')}</span>
607
+ <select
608
+ className={css.schedulePreset}
609
+ value={scheduleMode}
610
+ aria-label={t('detail.schedule.mode')}
611
+ onChange={event => { setScheduleMode(event.target.value as ScheduleMode); setScheduleError(undefined) }}
612
+ >
613
+ <option value="cron">{t('detail.schedule.mode.cron')}</option>
614
+ <option value="once">{t('detail.schedule.mode.once')}</option>
615
+ </select>
616
+ </label>
617
+ {scheduleMode === 'once' ? (
618
+ <label className={css.scheduleZone}>
619
+ <span>{t('detail.schedule.at')}</span>
620
+ <input
621
+ className={css.input}
622
+ type="datetime-local"
623
+ value={scheduleAt}
624
+ aria-label={t('detail.schedule.at')}
625
+ title={t('detail.schedule.atHint')}
626
+ onChange={event => { setScheduleAt(event.target.value); setScheduleError(undefined) }}
627
+ />
628
+ </label>
629
+ ) : (
630
+ <>
536
631
  <div className={css.scheduleRow}>
537
632
  <input
538
633
  className={`${css.input} ${css.scheduleInput}${scheduleError !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
@@ -558,6 +653,42 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
558
653
  ))}
559
654
  </select>
560
655
  </div>
656
+ <label className={css.scheduleZone}>
657
+ <span>{t('detail.schedule.maxRuns')}</span>
658
+ <select
659
+ className={css.schedulePreset}
660
+ value={scheduleMaxRunsChoice}
661
+ aria-label={t('detail.schedule.maxRuns')}
662
+ onChange={event => {
663
+ setScheduleMaxRunsChoice(event.target.value)
664
+ setScheduleError(undefined)
665
+ if (event.target.value === 'custom') {
666
+ setScheduleMaxRunsText(initialTask?.schedule?.maxRuns === undefined ? '' : String(initialTask.schedule.maxRuns))
667
+ }
668
+ }}
669
+ >
670
+ <option value="unlimited">{t('detail.schedule.maxRunsUnlimited')}</option>
671
+ <option value="1">{t('detail.schedule.maxRunsValue', { count: '1' })}</option>
672
+ <option value="2">{t('detail.schedule.maxRunsValue', { count: '2' })}</option>
673
+ <option value="custom">{t('detail.schedule.maxRunsCustom')}</option>
674
+ </select>
675
+ </label>
676
+ {scheduleMaxRunsChoice === 'custom' && (
677
+ <label className={css.scheduleZone}>
678
+ <span>{t('detail.schedule.maxRuns')}</span>
679
+ <input
680
+ className={css.input}
681
+ type="number"
682
+ min={1}
683
+ step={1}
684
+ value={scheduleMaxRunsText}
685
+ aria-label={t('detail.schedule.maxRuns')}
686
+ onChange={event => { setScheduleMaxRunsText(event.target.value); setScheduleError(undefined) }}
687
+ />
688
+ </label>
689
+ )}
690
+ </>
691
+ )}
561
692
  {scheduleError !== undefined && <p className={css.formError}>{scheduleError}</p>}
562
693
  <label className={css.scheduleZone}>
563
694
  <span>{t('detail.schedule.timeZone')}</span>
@@ -565,7 +696,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
565
696
  className={css.schedulePreset}
566
697
  value={scheduleZone}
567
698
  aria-label={t('detail.schedule.timeZone')}
568
- title={t('detail.schedule.timeZoneHint')}
699
+ title={scheduleMode === 'once' ? t('detail.schedule.zoneOnceHint') : t('detail.schedule.timeZoneHint')}
569
700
  onChange={event => { setScheduleZone(event.target.value); setScheduleError(undefined) }}
570
701
  >
571
702
  {zoneChoices(hostTimeZone, initialTask?.schedule?.timeZone).map(choice => (
@@ -274,11 +274,11 @@ function TaskCardInner({
274
274
  <span
275
275
  className={css.cardSchedule}
276
276
  title={task.schedule.nextRunAt !== undefined
277
- ? `${t('card.scheduled')} · ${formatHostTimestamp(task.schedule.nextRunAt, timeZone)}`
278
- : t('card.scheduled')}
277
+ ? `${t(task.schedule.mode === 'once' ? 'card.scheduledOnce' : 'card.scheduled')} · ${formatHostTimestamp(task.schedule.nextRunAt, timeZone)}`
278
+ : t(task.schedule.mode === 'once' ? 'card.scheduledOnce' : 'card.scheduled')}
279
279
  >
280
280
  <IconClock size={12} />
281
- {t('card.scheduled')}
281
+ {t(task.schedule.mode === 'once' ? 'card.scheduledOnce' : 'card.scheduled')}
282
282
  </span>
283
283
  )}
284
284
  {latest !== undefined && (