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

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 (83) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +34 -18
  3. package/README.zh.md +34 -18
  4. package/lib/client.js +2804 -1024
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2576 -416
  7. package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +61 -0
  9. package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
  10. package/lib/types/client/board/model-options.d.ts +67 -0
  11. package/lib/types/client/board/preset-label.d.ts +21 -0
  12. package/lib/types/client/board/preset-roster.d.ts +45 -0
  13. package/lib/types/client/board/team-run-promotion.d.ts +26 -0
  14. package/lib/types/client/index.d.ts +4 -5
  15. package/lib/types/client/locales.d.ts +81 -0
  16. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  17. package/lib/types/client/schedule-zone.d.ts +50 -0
  18. package/lib/types/core/card-defaults.d.ts +78 -0
  19. package/lib/types/core/controller.d.ts +41 -17
  20. package/lib/types/core/launch-width.d.ts +31 -0
  21. package/lib/types/core/poll-cadence.d.ts +31 -0
  22. package/lib/types/core/reasoning-effort.d.ts +57 -0
  23. package/lib/types/core/subtask.d.ts +30 -8
  24. package/lib/types/core/tasks.d.ts +163 -14
  25. package/lib/types/core/use-cases/task-create.d.ts +10 -5
  26. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  27. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  28. package/lib/types/core/verification-budget.d.ts +52 -0
  29. package/lib/types/core/verification.d.ts +376 -9
  30. package/lib/types/host/launch-admission.d.ts +72 -0
  31. package/lib/types/host/verification-gate.d.ts +24 -0
  32. package/lib/types/host/verification-runner.d.ts +38 -7
  33. package/lib/types/host-ledger.d.ts +91 -11
  34. package/lib/types/host-runner.d.ts +111 -13
  35. package/lib/types/host-service.d.ts +199 -8
  36. package/lib/types/index.d.ts +141 -0
  37. package/lib/types/protocol.d.ts +63 -12
  38. package/package.json +29 -33
  39. package/src/client/CardDefaultPresetField.tsx +101 -0
  40. package/src/client/TaskBoardSettingsCard.tsx +288 -17
  41. package/src/client/board/LinkSubtaskModal.tsx +8 -0
  42. package/src/client/board/NewTaskModal.tsx +237 -60
  43. package/src/client/board/ReasoningEffortField.tsx +50 -0
  44. package/src/client/board/TaskCard.tsx +3 -3
  45. package/src/client/board/TaskDetail.tsx +261 -24
  46. package/src/client/board/VerificationReport.tsx +132 -13
  47. package/src/client/board/model-options.ts +178 -0
  48. package/src/client/board/preset-label.ts +32 -0
  49. package/src/client/board/preset-roster.ts +58 -0
  50. package/src/client/board/status-key.ts +3 -0
  51. package/src/client/board/team-run-promotion.ts +39 -0
  52. package/src/client/index.ts +105 -108
  53. package/src/client/locales.ts +171 -6
  54. package/src/client/plugin-card-seat.ts +7 -0
  55. package/src/client/schedule-zone.ts +100 -2
  56. package/src/client/settings-entry-form.ts +0 -1
  57. package/src/core/card-defaults.ts +132 -0
  58. package/src/core/controller.ts +55 -16
  59. package/src/core/launch-width.ts +38 -0
  60. package/src/core/poll-cadence.ts +41 -0
  61. package/src/core/reasoning-effort.ts +92 -0
  62. package/src/core/store.ts +69 -15
  63. package/src/core/subtask.ts +48 -13
  64. package/src/core/tasks.ts +213 -39
  65. package/src/core/use-cases/task-create.ts +57 -25
  66. package/src/core/use-cases/task-schedule.ts +190 -25
  67. package/src/core/use-cases/task-update.ts +9 -4
  68. package/src/core/verification-budget.ts +115 -0
  69. package/src/core/verification.ts +785 -17
  70. package/src/host/agent-tools.ts +127 -29
  71. package/src/host/launch-admission.ts +140 -0
  72. package/src/host/verification-gate.ts +172 -16
  73. package/src/host/verification-runner.ts +207 -40
  74. package/src/host-ledger.ts +346 -48
  75. package/src/host-runner.ts +234 -35
  76. package/src/host-service.ts +539 -52
  77. package/src/index.ts +250 -7
  78. package/src/protocol.ts +160 -32
  79. package/lib/invariant.js +0 -6
  80. package/lib/types/client/board-card-seat.d.ts +0 -34
  81. package/lib/types/invariant.d.ts +0 -3
  82. package/src/client/board-card-seat.ts +0 -102
  83. package/src/invariant.ts +0 -4
@@ -9,13 +9,13 @@ import type { BoardController, ControllerSnapshot } from '../../core/controller.
9
9
  import type { TaskBoardExtensionActionRequest, TaskBoardExtensionDispatch } from '../../core/extension.ts'
10
10
  import { useTaskBoardSeats } from '../seats.tsx'
11
11
  import { isValidCron } from '../../core/schedule.ts'
12
- import { MANUAL_STATUSES, TASK_PERMISSIONS, canMoveTask, hasOpenExecution, tagTone, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
12
+ import { MANUAL_STATUSES, TASK_PERMISSIONS, canMoveTask, hasOpenExecution, tagTone, type ExecutionRecord, type ScheduleMode, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
13
13
  import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
14
14
  import { requiresPermissionConfirmation } from '../../core/handover.ts'
15
- import { DEFAULT_SUBTASK_DEPTH, directSubtasks, taskDepth } from '../../core/subtask.ts'
15
+ import { DEFAULT_SUBTASK_DEPTH, directSubtasks, joinsCascadeRun, taskDepth } from '../../core/subtask.ts'
16
16
  import { t, type TaskBoardKey } from '../locales.ts'
17
17
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
18
- import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
18
+ import { budgetLabel, defaultZonedInputValue, endedLabel, nextRunLabel, parseZonedInput, runCapOption, skippedLabel, zonedInputValue, zoneChoices } from '../schedule-zone.ts'
19
19
  import css from '../board.module.css'
20
20
  import { ConfirmDialog } from './ConfirmDialog.tsx'
21
21
  import { EditTaskModal, EditTagsModal } from './EditTaskModal.tsx'
@@ -24,6 +24,8 @@ import { LinkSubtaskModal } from './LinkSubtaskModal.tsx'
24
24
  import { NewTaskModal } from './NewTaskModal.tsx'
25
25
  import { useDialog, usePresence, type OverlayPhase } from './overlay.tsx'
26
26
  import { inheritPresetLabel, presetLabel } from './preset-label.ts'
27
+ import { ReasoningEffortField } from './ReasoningEffortField.tsx'
28
+ import { reconcileReasoningEffort } from './model-options.ts'
27
29
  import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
28
30
  import { STATUS_KEY } from './status-key.ts'
29
31
  import { VerificationReport } from './VerificationReport.tsx'
@@ -39,8 +41,12 @@ const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> =
39
41
  /** One execution-history row. */
40
42
  function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRecord; timeZone?: string; onOpen: (sessionId: string) => void }) {
41
43
  const result = execution.result
44
+ // An external record is one the Host never observed running: no session, no
45
+ // spinner, and a named outside agent instead of the initiating session. The
46
+ // data attribute keeps the distinction machine-readable too.
47
+ const external = execution.external === true
42
48
  return (
43
- <li className={css.executionRow} data-result={result}>
49
+ <li className={css.executionRow} data-result={result} data-external={external ? 'true' : undefined}>
44
50
  <span className={css.executionBadge} data-result={result}>
45
51
  {result === undefined ? t('detail.result.running') : t(RESULT_KEY[result])}
46
52
  </span>
@@ -48,7 +54,12 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
48
54
  {t('detail.executionStarted')} {formatTime(execution.startedAt, timeZone)}
49
55
  {execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt, timeZone)}`}
50
56
  </span>
51
- {execution.initiatedBy !== undefined && (
57
+ {external && execution.initiatedBy !== undefined && (
58
+ <span className={css.executionTimes} title={execution.initiatedBy}>
59
+ {t('detail.execution.external', { agent: execution.initiatedBy })}
60
+ </span>
61
+ )}
62
+ {!external && execution.initiatedBy !== undefined && (
52
63
  <span className={css.executionTimes} title={execution.initiatedBy}>
53
64
  {t('detail.execution.initiator', { session: execution.initiatedBy })}
54
65
  </span>
@@ -73,7 +84,7 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
73
84
  }
74
85
 
75
86
  /** The execution-target editor: workspace / mode / permission pickers. */
76
- function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable }: {
87
+ function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable, goalRunEnabled }: {
77
88
  controller: BoardController
78
89
  task: TaskRecord
79
90
  pending: boolean
@@ -81,18 +92,26 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
81
92
  executionOptions: ControllerSnapshot['executionOptions']
82
93
  /** Whether this deployment serves the Agent Teams service. */
83
94
  teamRunAvailable: boolean
95
+ /**
96
+ * Live GLOBAL native-/goal switch. It only decides whether the card's own
97
+ * preference takes effect: the option stays operable while it is off, the
98
+ * stored value is never rewritten by it, and the explanation below says so.
99
+ */
100
+ goalRunEnabled: boolean
84
101
  }) {
85
102
  const options = executionOptions
103
+ const models = options.models ?? []
86
104
  const workspaceId = task.workspaceId ?? ''
87
105
  const mode = task.mode ?? ''
88
106
  const permission = task.permission ?? ''
89
107
  const model = task.model ?? ''
108
+ const reasoningEffort = task.reasoningEffort ?? ''
90
109
  // A pinned target may disappear from the runtime (workspace deleted,
91
110
  // preset removed); keep it selectable as a stale row instead of silently
92
111
  // dropping it, so the user sees exactly what the task will ask for.
93
112
  const workspaceKnown = workspaceId === '' || options.workspaces.some(item => item.workspaceId === workspaceId)
94
113
  const modeKnown = mode === '' || options.presets.some(item => item.id === mode)
95
- const modelKnown = model === '' || (options.models ?? []).some(item => item.id === model)
114
+ const modelKnown = model === '' || models.some(item => item.id === model)
96
115
  return (
97
116
  <section className={css.detailSection}>
98
117
  <h4>{t('detail.executionSettings')}</h4>
@@ -151,15 +170,35 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
151
170
  className={css.select}
152
171
  value={model}
153
172
  disabled={pending}
154
- onChange={event => { controller.updateTask(task.id, { model: event.target.value === '' ? undefined : event.target.value }) }}
173
+ onChange={event => {
174
+ // The effort is part of this pin: switching to a model that cannot
175
+ // honor the stored level clears it in the same action instead of
176
+ // leaving a value the next run would have to reject. The empty
177
+ // string carries a clear across the JSON transport, where an
178
+ // `undefined` value would lose the key entirely; the ledger
179
+ // normalizes blank targets back to "pinned nothing".
180
+ const next = event.target.value
181
+ const effort = reconcileReasoningEffort(models, next, reasoningEffort)
182
+ controller.updateTask(task.id, {
183
+ model: next,
184
+ ...(effort === reasoningEffort ? {} : { reasoningEffort: effort }),
185
+ })
186
+ }}
155
187
  >
156
188
  <option value="">{t('exec.model.default')}</option>
157
189
  {!modelKnown && <option value={model}>{model}{t('exec.model.unknown')}</option>}
158
- {options.models?.map(item => (
190
+ {models.map(item => (
159
191
  <option key={item.id} value={item.id}>{item.name ?? item.id}</option>
160
192
  ))}
161
193
  </select>
162
194
  </label>
195
+ <ReasoningEffortField
196
+ models={models}
197
+ modelId={model}
198
+ value={reasoningEffort}
199
+ pending={pending}
200
+ onChange={effort => { controller.updateTask(task.id, { reasoningEffort: effort }) }}
201
+ />
163
202
  <label className={css.scheduleToggle}>
164
203
  <input
165
204
  type="checkbox"
@@ -173,13 +212,17 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
173
212
  <label className={css.scheduleToggle}>
174
213
  <input
175
214
  type="checkbox"
176
- checked={task.goalRun !== false}
215
+ checked={task.goalRun === true}
216
+ // Always operable (only a pending execution locks it): the global
217
+ // switch decides whether the preference takes effect, never whether
218
+ // the user may state it, and it never rewrites what is stored here.
177
219
  disabled={pending}
178
220
  onChange={event => { controller.updateTask(task.id, { goalRun: event.target.checked }) }}
179
221
  />
180
222
  <span>{t('exec.goalRun')}</span>
181
223
  </label>
182
224
  <p className={css.detailText}>{t('exec.goalRunHint')}</p>
225
+ {!goalRunEnabled && <p className={css.detailText}>{t('settings.goalRunGlobalDisabledTaskOption')}</p>}
183
226
  <label className={css.scheduleToggle}>
184
227
  <input
185
228
  type="checkbox"
@@ -204,31 +247,49 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
204
247
  )
205
248
  }
206
249
 
207
- /** The scheduled-runs editor: enable toggle, cron input + presets, zone, next-run info. */
250
+ /**
251
+ * The scheduled-runs editor. A one-shot has one planned instant; a recurring
252
+ * rule has a cron expression, an optional run cap, and a zone. The editor
253
+ * shows the rule's own state (next target, run counter, stop/skip record) and
254
+ * can cancel a future plan.
255
+ */
208
256
  function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
209
257
  const schedule = task.schedule
258
+ const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
259
+ // The rule's own zone; '' means "follow the Host zone" (no stored zone).
260
+ const [zone, setZone] = useState<string>(schedule?.timeZone ?? '')
261
+ const [mode, setMode] = useState<ScheduleMode>(schedule?.mode ?? 'cron')
210
262
  const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
263
+ const [atText, setAtText] = useState<string>(() => schedule?.at === undefined
264
+ ? defaultZonedInputValue(Date.now(), schedule?.timeZone ?? hostTimeZone)
265
+ : zonedInputValue(schedule.at, schedule?.timeZone ?? hostTimeZone))
266
+ const [maxRunsChoice, setMaxRunsChoice] = useState<string>(() => runCapOption(schedule?.maxRuns))
267
+ const [maxRunsText, setMaxRunsText] = useState<string>(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
211
268
  const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
212
269
  const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
213
270
  const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
214
271
  const [error, setError] = useState<string | undefined>(undefined)
215
- const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
216
- // The rule's own zone; '' means "follow the Host zone" (no stored zone).
217
- const [zone, setZone] = useState<string>(schedule?.timeZone ?? '')
218
272
  // One clock reading per render pass, so the absolute and relative halves of
219
273
  // the label always describe the same instant.
220
274
  const now = Date.now()
221
275
 
222
276
  // Keep the editor in sync when the task record changes underneath (the
223
- // schedule rolls forward as runs trigger).
277
+ // schedule rolls forward as runs trigger, ends at its budget, or is edited
278
+ // elsewhere).
224
279
  useEffect(() => {
280
+ setMode(schedule?.mode ?? 'cron')
225
281
  setCron(schedule?.cron ?? '0 9 * * *')
282
+ setZone(schedule?.timeZone ?? '')
283
+ setAtText(schedule?.at === undefined
284
+ ? defaultZonedInputValue(Date.now(), schedule?.timeZone ?? hostTimeZone)
285
+ : zonedInputValue(schedule.at, schedule?.timeZone ?? hostTimeZone))
286
+ setMaxRunsChoice(runCapOption(schedule?.maxRuns))
287
+ setMaxRunsText(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
226
288
  setEnabled(schedule?.enabled ?? false)
227
289
  setNextRunAt(schedule?.nextRunAt)
228
290
  setLastTriggeredAt(schedule?.lastTriggeredAt)
229
- setZone(schedule?.timeZone ?? '')
230
291
  setError(undefined)
231
- }, [task.id, schedule?.enabled, schedule?.cron, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt])
292
+ }, [task.id, hostTimeZone, schedule?.mode, schedule?.enabled, schedule?.cron, schedule?.at, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt, schedule?.maxRuns])
232
293
 
233
294
  /** Validate + persist the current cron text (Enter or blur). */
234
295
  const saveCron = (value: string): void => {
@@ -239,21 +300,101 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
239
300
  return
240
301
  }
241
302
  setError(undefined)
242
- controller.setSchedule(task.id, { cron: trimmed })
303
+ controller.setSchedule(task.id, { mode: 'cron', cron: trimmed })
304
+ }
305
+
306
+ /** Validate + persist the one-shot's planned instant. */
307
+ const saveAt = (value: string): void => {
308
+ setAtText(value)
309
+ const at = parseZonedInput(value, effectiveZone)
310
+ if (at === undefined || at <= now) {
311
+ setError(t('detail.schedule.atInvalid'))
312
+ return
313
+ }
314
+ setError(undefined)
315
+ controller.setSchedule(task.id, { mode: 'once', at })
316
+ }
317
+
318
+ /** Switch the kind of plan, persisting a usable target for the new kind. */
319
+ const changeMode = (next: ScheduleMode): void => {
320
+ if (next === 'once') {
321
+ const at = parseZonedInput(atText, effectiveZone)
322
+ if (at === undefined || at <= now) {
323
+ setError(t('detail.schedule.atInvalid'))
324
+ return
325
+ }
326
+ setMode('once')
327
+ setError(undefined)
328
+ controller.setSchedule(task.id, { mode: 'once', at })
329
+ return
330
+ }
331
+ const trimmed = cron.trim()
332
+ if (trimmed === '' || !isValidCron(trimmed)) {
333
+ setError(t('detail.schedule.invalid'))
334
+ return
335
+ }
336
+ setMode('cron')
337
+ setError(undefined)
338
+ controller.setSchedule(task.id, { mode: 'cron', cron: trimmed })
243
339
  }
244
340
 
245
341
  /**
246
342
  * Change the rule's zone. `''` clears the stored zone, which is how a user
247
- * returns a rule to following the Host zone.
343
+ * returns a rule to following the Host zone. A one-shot's instant is
344
+ * absolute, so only its rendered wall clock follows the new zone; a cron
345
+ * rule's wall clock itself moves with the zone.
248
346
  */
249
347
  const changeZone = (value: string): void => {
250
348
  setZone(value)
251
349
  setError(undefined)
350
+ if (mode === 'once' && schedule?.at !== undefined) {
351
+ setAtText(zonedInputValue(schedule.at, value === '' ? hostTimeZone : value))
352
+ controller.setSchedule(task.id, { mode: 'once', timeZone: value === '' ? null : value })
353
+ return
354
+ }
252
355
  controller.setSchedule(task.id, { timeZone: value === '' ? null : value })
253
356
  }
254
357
 
255
- /** Arm/disarm the schedule (arming first persists the edited cron). */
358
+ /** Change the recurring rule's run cap (unlimited, a preset, or a custom value). */
359
+ const applyMaxRuns = (choice: string): void => {
360
+ setMaxRunsChoice(choice)
361
+ setError(undefined)
362
+ if (choice === 'unlimited') {
363
+ controller.setSchedule(task.id, { maxRuns: null })
364
+ return
365
+ }
366
+ if (choice === 'custom') {
367
+ setMaxRunsText(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
368
+ return
369
+ }
370
+ controller.setSchedule(task.id, { maxRuns: Number(choice) })
371
+ }
372
+
373
+ /** Persist a custom run cap; a non-positive or fractional value is refused. */
374
+ const saveCustomMaxRuns = (value: string): void => {
375
+ setMaxRunsText(value)
376
+ const parsed = Number(value.trim())
377
+ if (value.trim() === '' || !Number.isInteger(parsed) || parsed < 1) {
378
+ setError(t('detail.schedule.maxRunsInvalid'))
379
+ return
380
+ }
381
+ setError(undefined)
382
+ controller.setSchedule(task.id, { maxRuns: parsed })
383
+ }
384
+
385
+ /** Arm/disarm the schedule (arming first persists the edited plan). */
256
386
  const toggleEnabled = (next: boolean): void => {
387
+ if (next && mode === 'once') {
388
+ const at = parseZonedInput(atText, effectiveZone)
389
+ if (at === undefined || at <= now) {
390
+ setError(t('detail.schedule.atInvalid'))
391
+ return
392
+ }
393
+ setError(undefined)
394
+ const submitted = controller.setSchedule(task.id, { mode: 'once', at, enabled: true })
395
+ if (submitted && !controller.isHostBacked()) setEnabled(true)
396
+ return
397
+ }
257
398
  const trimmed = cron.trim()
258
399
  if (next && (trimmed === '' || !isValidCron(trimmed))) {
259
400
  setError(t('detail.schedule.invalid'))
@@ -267,11 +408,19 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
267
408
  if (submitted && !controller.isHostBacked()) setEnabled(next)
268
409
  }
269
410
 
411
+ /** Drop every future trigger without touching the card's execution history. */
412
+ const cancelSchedule = (): void => {
413
+ setError(undefined)
414
+ const submitted = controller.setSchedule(task.id, { enabled: false })
415
+ if (submitted && !controller.isHostBacked()) setEnabled(false)
416
+ }
417
+
270
418
  const applyPreset = (preset: string): void => {
271
419
  if (preset === '') return
272
420
  setCron(preset)
421
+ setMode('cron')
273
422
  setError(undefined)
274
- controller.setSchedule(task.id, { cron: preset })
423
+ controller.setSchedule(task.id, { mode: 'cron', cron: preset })
275
424
  }
276
425
 
277
426
  // The zone the rule's wall clock is actually read in: its own when set,
@@ -286,6 +435,11 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
286
435
  // The rule's own stored zone rides the list even when this runtime's
287
436
  // inventory does not carry it, so opening the editor cannot rewrite it.
288
437
  const zones = zoneChoices(hostTimeZone, schedule?.timeZone)
438
+ // The ledger's own view of the budget and stop record: derived here rather
439
+ // than tracked in local state, so the display cannot drift from the Host.
440
+ const budget = schedule === undefined ? undefined : budgetLabel(schedule)
441
+ const ended = schedule === undefined ? undefined : endedLabel(schedule)
442
+ const skipped = schedule === undefined ? undefined : skippedLabel(schedule)
289
443
 
290
444
  return (
291
445
  <section className={css.detailSection}>
@@ -299,6 +453,35 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
299
453
  />
300
454
  <span>{t('detail.schedule.enable')}</span>
301
455
  </label>
456
+ <label className={css.scheduleZone}>
457
+ <span>{t('detail.schedule.mode')}</span>
458
+ <select
459
+ className={css.schedulePreset}
460
+ value={mode}
461
+ disabled={pending}
462
+ aria-label={t('detail.schedule.mode')}
463
+ onChange={event => { changeMode(event.target.value as ScheduleMode) }}
464
+ >
465
+ <option value="cron">{t('detail.schedule.mode.cron')}</option>
466
+ <option value="once">{t('detail.schedule.mode.once')}</option>
467
+ </select>
468
+ </label>
469
+ {mode === 'once' ? (
470
+ <label className={css.scheduleZone}>
471
+ <span>{t('detail.schedule.at')}</span>
472
+ <input
473
+ className={css.input}
474
+ type="datetime-local"
475
+ value={atText}
476
+ disabled={pending}
477
+ aria-label={t('detail.schedule.at')}
478
+ title={t('detail.schedule.atHint')}
479
+ onChange={event => { setAtText(event.target.value); setError(undefined) }}
480
+ onBlur={() => { saveAt(atText) }}
481
+ />
482
+ </label>
483
+ ) : (
484
+ <>
302
485
  <div className={css.scheduleRow}>
303
486
  <input
304
487
  className={`${css.input} ${css.scheduleInput}${error !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
@@ -324,6 +507,39 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
324
507
  ))}
325
508
  </select>
326
509
  </div>
510
+ <label className={css.scheduleZone}>
511
+ <span>{t('detail.schedule.maxRuns')}</span>
512
+ <select
513
+ className={css.schedulePreset}
514
+ value={maxRunsChoice}
515
+ disabled={pending}
516
+ aria-label={t('detail.schedule.maxRuns')}
517
+ onChange={event => { applyMaxRuns(event.target.value) }}
518
+ >
519
+ <option value="unlimited">{t('detail.schedule.maxRunsUnlimited')}</option>
520
+ <option value="1">{t('detail.schedule.maxRunsValue', { count: '1' })}</option>
521
+ <option value="2">{t('detail.schedule.maxRunsValue', { count: '2' })}</option>
522
+ <option value="custom">{t('detail.schedule.maxRunsCustom')}</option>
523
+ </select>
524
+ </label>
525
+ {maxRunsChoice === 'custom' && (
526
+ <label className={css.scheduleZone}>
527
+ <span>{t('detail.schedule.maxRuns')}</span>
528
+ <input
529
+ className={css.input}
530
+ type="number"
531
+ min={1}
532
+ step={1}
533
+ value={maxRunsText}
534
+ disabled={pending}
535
+ aria-label={t('detail.schedule.maxRuns')}
536
+ onChange={event => { setMaxRunsText(event.target.value) }}
537
+ onBlur={() => { saveCustomMaxRuns(maxRunsText) }}
538
+ />
539
+ </label>
540
+ )}
541
+ </>
542
+ )}
327
543
  {/* A list is used instead of a free-text field so only zones the Host can
328
544
  also resolve are offered; the Host zone entry clears the stored value. */}
329
545
  <label className={css.scheduleZone}>
@@ -333,7 +549,7 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
333
549
  value={zone}
334
550
  disabled={pending}
335
551
  aria-label={t('detail.schedule.timeZone')}
336
- title={t('detail.schedule.timeZoneHint')}
552
+ title={mode === 'once' ? t('detail.schedule.zoneOnceHint') : t('detail.schedule.timeZoneHint')}
337
553
  onChange={event => { changeZone(event.target.value) }}
338
554
  >
339
555
  {zones.map(choice => (
@@ -346,10 +562,30 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
346
562
  {t('detail.schedule.nextRun')} {nextLabel}
347
563
  {' · '}{t('detail.schedule.lastTriggered')} {lastLabel}
348
564
  </p>
565
+ {budget !== undefined && <p className={css.scheduleMeta}>{budget}</p>}
566
+ {ended !== undefined && <p className={css.scheduleMeta}>{ended}</p>}
567
+ {ended === undefined && skipped !== undefined && <p className={css.scheduleMeta}>{skipped}</p>}
568
+ {enabled && (
569
+ <button type="button" className={css.ghostButton} disabled={pending} onClick={cancelSchedule}>
570
+ {t('detail.schedule.cancel')}
571
+ </button>
572
+ )}
349
573
  </section>
350
574
  )
351
575
  }
352
576
 
577
+ /**
578
+ * The run hint of one card: how many subtasks a run opens, counting the same
579
+ * members the Host will launch (`joinsCascadeRun` leaves a finished subtask
580
+ * alone), or why it opens none even though the card has subtasks.
581
+ */
582
+ function subtaskRunHint(parentId: string, children: readonly TaskRecord[]): string {
583
+ const runnable = children.filter(child => joinsCascadeRun(child, parentId))
584
+ return runnable.length > 0
585
+ ? t('detail.subtasks.runHint', { count: String(runnable.length) })
586
+ : t('detail.subtasks.runHintSettled')
587
+ }
588
+
353
589
  /**
354
590
  * The subtask block: the parent link, the direct subtasks with their status,
355
591
  * and the actions that grow or prune the tree. Every gate here mirrors the
@@ -420,7 +656,7 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
420
656
  </ul>
421
657
  )}
422
658
  {children.length > 0 && (
423
- <p className={css.detailText}>{t('detail.subtasks.runHint', { count: String(children.length) })}</p>
659
+ <p className={css.detailText}>{subtaskRunHint(task.id, children)}</p>
424
660
  )}
425
661
  {!archived && (canAddChild
426
662
  ? (controller.isHostBacked()
@@ -611,6 +847,7 @@ export function TaskDetail({ controller, task, phase = 'open' }: { controller: B
611
847
  pending={pending}
612
848
  executionOptions={snapshot.executionOptions}
613
849
  teamRunAvailable={snapshot.host?.teamRunAvailable === true}
850
+ goalRunEnabled={snapshot.host?.goalRunEnabled === true}
614
851
  />
615
852
  <ScheduleSection controller={controller} task={current} pending={pending} />
616
853
  </>
@@ -693,7 +930,7 @@ export function TaskDetail({ controller, task, phase = 'open' }: { controller: B
693
930
  className={css.primaryButton}
694
931
  disabled={busy || pending}
695
932
  title={subtaskChildren.length > 0
696
- ? t('detail.subtasks.runHint', { count: String(subtaskChildren.length) })
933
+ ? subtaskRunHint(current.id, subtaskChildren)
697
934
  : undefined}
698
935
  onClick={() => {
699
936
  void controller.rerunTask(current.id).then(() => {