@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
@@ -4,18 +4,22 @@
4
4
  */
5
5
  import { useEffect, useRef, useState } from 'react'
6
6
  import type { BoardController } from '../../core/controller.ts'
7
+ import { resolveCardTargets, type CardTargetSource } from '../../core/card-defaults.ts'
7
8
  import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
8
9
  import { parseFreezeRequest } from '../../core/freeze-snapshot.ts'
9
10
  import { effectiveTaskPermission } from '../../core/subtask.ts'
10
- import { collectKnownTags, TASK_PERMISSIONS, type TaskPermission, type TaskRecord, type TaskTag } from '../../core/tasks.ts'
11
+ import { collectKnownTags, TASK_PERMISSIONS, type ScheduleMode, type TaskPermission, type TaskRecord, type TaskTag } from '../../core/tasks.ts'
11
12
  import { t, type TaskBoardKey } from '../locales.ts'
12
13
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
13
- import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
14
+ import { defaultZonedInputValue, nextRunLabel, parseZonedInput, runCapOption, zonedInputValue, zoneChoices } from '../schedule-zone.ts'
14
15
  import { CollapsibleSection, ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx'
15
16
  import type { OverlayPhase } from './overlay.tsx'
16
17
  import { formatHostTimestamp } from './TaskCard.tsx'
17
18
  import { readParseModelPreference, writeParseModelPreference } from './parse-model-pref.ts'
18
- import { inheritPresetLabel, isBuiltinPreset, presetLabel } from './preset-label.ts'
19
+ import { inheritPresetLabel, presetChoiceRows, presetLabel } from './preset-label.ts'
20
+ import { ReasoningEffortField } from './ReasoningEffortField.tsx'
21
+ import { reconcileReasoningEffort } from './model-options.ts'
22
+ import { parentAwaitsTeamRunPromotion, promoteParentForTeamRun } from './team-run-promotion.ts'
19
23
  import css from '../board.module.css'
20
24
 
21
25
  export interface NewTaskModalProps {
@@ -46,23 +50,48 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
46
50
  const [title, setTitle] = useState(initialTask?.title ?? '')
47
51
  const [description, setDescription] = useState(initialTask?.description ?? '')
48
52
  const [prompt, setPrompt] = useState(initialTask?.prompt ?? '')
53
+ // The four execution targets this dialog opens on, resolved by the one rule
54
+ // the Host create path also uses (core/card-defaults.ts): what the card is
55
+ // created from (a duplicate's source, a subtask's parent) first, the board's
56
+ // new-card defaults second, blank when neither supplies a value. Resolving it
57
+ // from the SAME function the ledger uses is what keeps the dialog from
58
+ // drifting from what a create without an explicit choice stores.
59
+ const [seed] = useState(() => {
60
+ const source: CardTargetSource | undefined = parentTask !== undefined
61
+ ? { kind: 'parent', task: parentTask }
62
+ : initialTask !== undefined ? { kind: 'duplicate', task: initialTask } : undefined
63
+ return resolveCardTargets(controller.getSnapshot().host?.cardDefaults, source)
64
+ })
49
65
  // A subtask starts from its parent's execution contract (issue: subtask
50
66
  // inheritance); each picker below still overrides it for this card. The
51
- // permission stays UNSET by default: leaving it empty is what makes the Host
52
- // inherit the parent's binding together with its human confirmation, while
53
- // picking a value pins this card's own binding and re-arms the gate.
67
+ // permission stays UNSET unless a default supplies one: leaving it empty is
68
+ // what makes the Host inherit the parent's binding together with its human
69
+ // confirmation, while picking a value pins this card's own binding and
70
+ // re-arms the gate. A parent's permission never travels here at all.
54
71
  const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? parentTask?.workspaceId ?? defaultWorkspaceId ?? '')
55
- const [mode, setMode] = useState(initialTask?.mode ?? parentTask?.mode ?? '')
56
- const [permission, setPermission] = useState(initialTask?.permission ?? '')
57
- const [model, setModel] = useState(initialTask?.model ?? parentTask?.model ?? '')
72
+ const [mode, setMode] = useState(seed.mode)
73
+ const [permission, setPermission] = useState(seed.permission)
74
+ const [model, setModel] = useState(seed.model)
75
+ // The effort belongs to the model pin: the resolved seed carries the source
76
+ // card's pair and applies the configured default level only while the final
77
+ // model IS the configured default model; '' follows the model/Host default.
78
+ const [reasoningEffort, setReasoningEffort] = useState(seed.reasoningEffort)
58
79
  const inheritedPermission = parentTask === undefined ? undefined : effectiveTaskPermission(parentTask)
59
80
  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)
81
+ // Unchecked by default: a new task runs one plain turn unless the user opts
82
+ // into dsh's built-in /goal here. A duplicate keeps the original card's choice.
83
+ const [goalRun, setGoalRun] = useState(initialTask?.goalRun ?? false)
63
84
  const [skipVerification, setSkipVerification] = useState(initialTask?.skipVerification ?? false)
64
85
  const [scheduleEnabled, setScheduleEnabled] = useState(initialTask?.schedule?.enabled ?? false)
86
+ const [scheduleMode, setScheduleMode] = useState<ScheduleMode>(initialTask?.schedule?.mode ?? 'cron')
65
87
  const [scheduleCron, setScheduleCron] = useState(initialTask?.schedule?.cron ?? '')
88
+ // A duplicate keeps the original card's plan; a fresh one-shot defaults to
89
+ // the next hour, so the field never opens on a past instant.
90
+ const [scheduleAt, setScheduleAt] = useState<string>(() => initialTask?.schedule?.mode === 'once' && initialTask.schedule.at !== undefined
91
+ ? zonedInputValue(initialTask.schedule.at, initialTask.schedule.timeZone)
92
+ : defaultZonedInputValue(Date.now(), initialTask?.schedule?.timeZone))
93
+ const [scheduleMaxRunsChoice, setScheduleMaxRunsChoice] = useState<string>(() => runCapOption(initialTask?.schedule?.maxRuns))
94
+ const [scheduleMaxRunsText, setScheduleMaxRunsText] = useState<string>(initialTask?.schedule?.maxRuns === undefined ? '' : String(initialTask.schedule.maxRuns))
66
95
  // '' means "follow the Host zone" (store no zone), which is the default a
67
96
  // user gets unless they pick one explicitly.
68
97
  const [scheduleZone, setScheduleZone] = useState(initialTask?.schedule?.timeZone ?? '')
@@ -77,6 +106,11 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
77
106
  const [error, setError] = useState<string | undefined>(undefined)
78
107
  const [pending, setPending] = useState(false)
79
108
  const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
109
+ // Live GLOBAL native-/goal switch. The new-task form never rewrites a stored
110
+ // preference from it: the checkbox stays operable and is explained while the
111
+ // master switch is off, and the user's own choice is what a later re-enable
112
+ // restores.
113
+ const [goalRunEnabled, setGoalRunEnabled] = useState(controller.getSnapshot().host?.goalRunEnabled === true)
80
114
  // "Parse pasted text" (issue #1540) exists only when the deployment carries a
81
115
  // parse face; the section stays hidden otherwise.
82
116
  const [canParse] = useState(controller.getSnapshot().canParseTask === true)
@@ -87,19 +121,23 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
87
121
  const [parsePending, setParsePending] = useState(false)
88
122
  const [parseError, setParseError] = useState<string | undefined>(undefined)
89
123
  const parseAbort = useRef<AbortController | undefined>(undefined)
90
- const parseModels = options.models ?? []
124
+ const models = options.models ?? []
91
125
  // A pinned target may have disappeared from the runtime (workspace deleted,
92
126
  // preset removed) — especially when the form starts from a subtask's parent.
93
127
  // Keep it selectable as a stale row, so the field shows what the create
94
128
  // action will actually send instead of silently displaying the default.
95
129
  const workspaceKnown = workspaceId === '' || options.workspaces.some(item => item.workspaceId === workspaceId)
96
130
  const modeKnown = mode === '' || options.presets.some(item => item.id === mode)
97
- const modelKnown = model === '' || parseModels.some(item => item.id === model)
131
+ const modelKnown = model === '' || models.some(item => item.id === model)
98
132
 
99
133
  // The workspace list and preset roster arrive from the runtime after mount;
100
134
  // follow them so the pickers never freeze on an empty snapshot.
101
135
  useEffect(
102
- () => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)),
136
+ () => controller.subscribe(() => {
137
+ const snapshot = controller.getSnapshot()
138
+ setOptions(snapshot.executionOptions)
139
+ setGoalRunEnabled(snapshot.host?.goalRunEnabled === true)
140
+ }),
103
141
  [controller],
104
142
  )
105
143
 
@@ -107,8 +145,8 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
107
145
  // no longer offers falls back to the Host default; an empty value is the
108
146
  // Host-default choice and is never overwritten by the roster (issue #1621).
109
147
  useEffect(() => {
110
- if (parseModel === '' || parseModels.length === 0) return
111
- if (parseModels.some(option => option.id === parseModel)) return
148
+ if (parseModel === '' || models.length === 0) return
149
+ if (models.some(option => option.id === parseModel)) return
112
150
  setParseModel('')
113
151
  writeParseModelPreference('')
114
152
  }, [parseModel, options.models])
@@ -145,10 +183,25 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
145
183
  */
146
184
  const submit = async (runAfterCreate: boolean): Promise<void> => {
147
185
  if (scheduleEnabled) {
148
- const cron = scheduleCron.trim()
149
- if (cron === '' || !isValidCron(cron)) {
150
- setScheduleError(t('detail.schedule.invalid'))
151
- return
186
+ if (scheduleMode === 'once') {
187
+ const at = parseZonedInput(scheduleAt, scheduleZone === '' ? hostTimeZone : scheduleZone)
188
+ if (at === undefined || at <= Date.now()) {
189
+ setScheduleError(t('detail.schedule.atInvalid'))
190
+ return
191
+ }
192
+ } else {
193
+ const cron = scheduleCron.trim()
194
+ if (cron === '' || !isValidCron(cron)) {
195
+ setScheduleError(t('detail.schedule.invalid'))
196
+ return
197
+ }
198
+ if (scheduleMaxRunsChoice === 'custom') {
199
+ const cap = Number(scheduleMaxRunsText.trim())
200
+ if (scheduleMaxRunsText.trim() === '' || !Number.isInteger(cap) || cap < 1) {
201
+ setScheduleError(t('detail.schedule.maxRunsInvalid'))
202
+ return
203
+ }
204
+ }
152
205
  }
153
206
  }
154
207
  // Optional continuation-card snapshot: parse the freeze block through the
@@ -183,16 +236,36 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
183
236
  ...(parentTask === undefined ? {} : { parentId: parentTask.id }),
184
237
  freeze,
185
238
  handover,
186
- workspaceId: workspaceId === '' ? undefined : workspaceId,
187
- mode: mode === '' ? undefined : mode,
188
- permission: permission === '' ? undefined : permission as TaskPermission,
189
- model: model === '' ? undefined : model,
239
+ // A cleared picker is OMITTED, never sent as an empty value: absence is
240
+ // how "no pin, resolved at run time" is stored, so blanking a field the
241
+ // dialog opened prefilled really unpins it. This dialog never sends
242
+ // `applyCardDefaults` — the prefill above is the whole feature here.
243
+ ...(workspaceId === '' ? {} : { workspaceId }),
244
+ ...(mode === '' ? {} : { mode }),
245
+ ...(permission === '' ? {} : { permission: permission as TaskPermission }),
246
+ ...(model === '' ? {} : { model }),
247
+ ...(reasoningEffort === '' ? {} : { reasoningEffort }),
190
248
  ...(reuseSession ? { reuseSession: true } : {}),
191
- ...(goalRun ? {} : { goalRun: false }),
249
+ ...(goalRun ? { goalRun: true } : {}),
192
250
  ...(skipVerification ? { skipVerification: true } : {}),
193
251
  ...(tagList.length > 0 ? { tags: tagList } : {}),
194
252
  schedule: scheduleEnabled
195
- ? { enabled: true, cron: scheduleCron.trim(), ...(scheduleZone === '' ? {} : { timeZone: scheduleZone }) }
253
+ ? scheduleMode === 'once'
254
+ ? {
255
+ enabled: true,
256
+ mode: 'once' as const,
257
+ at: parseZonedInput(scheduleAt, scheduleZone === '' ? hostTimeZone : scheduleZone)!,
258
+ ...(scheduleZone === '' ? {} : { timeZone: scheduleZone }),
259
+ }
260
+ : {
261
+ enabled: true,
262
+ mode: 'cron' as const,
263
+ cron: scheduleCron.trim(),
264
+ ...(scheduleMaxRunsChoice === 'unlimited' ? {} : {
265
+ maxRuns: scheduleMaxRunsChoice === 'custom' ? Number(scheduleMaxRunsText.trim()) : Number(scheduleMaxRunsChoice),
266
+ }),
267
+ ...(scheduleZone === '' ? {} : { timeZone: scheduleZone }),
268
+ }
196
269
  : undefined,
197
270
  })
198
271
  if (task === undefined) {
@@ -200,6 +273,11 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
200
273
  setError(controller.getSnapshot().transportError ?? t('new.required'))
201
274
  return
202
275
  }
276
+ // The join is confirmed, so a parent that just gained a subtask is opted in
277
+ // where Agent Teams are served. The write is a convenience, not part of the
278
+ // creation: a refused one (the card was archived meanwhile) already
279
+ // surfaced through the snapshot and never fails the created task.
280
+ await promoteParentForTeamRun(controller, parentTask)
203
281
  if (isDuplicate && archiveOriginal && initialTask !== undefined) {
204
282
  if (onDuplicateSuccess !== undefined) {
205
283
  await onDuplicateSuccess(initialTask.id)
@@ -218,23 +296,29 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
218
296
  onClose()
219
297
  }
220
298
 
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.
299
+ // Next-run preview for the armed plan (creation-time only), computed in the
300
+ // selected zone so it matches what the Host will arm. A one-shot's next run
301
+ // IS its planned instant; a recurring rule resolves its next occurrence.
223
302
  const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
224
303
  const scheduleTimeZone = scheduleZone === '' ? hostTimeZone : scheduleZone
225
- const scheduleNextRun = scheduleEnabled && scheduleCron.trim() !== '' && isValidCron(scheduleCron)
226
- ? nextRunAtMs(scheduleCron, Date.now(), scheduleTimeZone)
227
- : undefined
304
+ const scheduleNextRun = !scheduleEnabled
305
+ ? undefined
306
+ : scheduleMode === 'once'
307
+ ? parseZonedInput(scheduleAt, scheduleTimeZone)
308
+ : scheduleCron.trim() !== '' && isValidCron(scheduleCron)
309
+ ? nextRunAtMs(scheduleCron, Date.now(), scheduleTimeZone)
310
+ : undefined
228
311
 
229
312
  const modalTitle = parentTask !== undefined
230
313
  ? t('new.subtaskTitle')
231
314
  : isDuplicate ? t('new.duplicateTitle') : t('board.new')
232
315
 
233
316
  // Agent-preset picker rows: the four built-in presets first, then every other
234
- // roster row (user presets). The inherit option names the preset a run
235
- // actually lands on when the card pins nothing.
236
- const builtinPresets = options.presets.filter(preset => isBuiltinPreset(preset.id))
237
- const customPresets = options.presets.filter(preset => !isBuiltinPreset(preset.id))
317
+ // roster row (user presets), each labelled with its default/unavailable
318
+ // suffixes by the one row builder the settings card's default-preset field
319
+ // also renders through. The inherit option names the preset a run actually
320
+ // lands on when the card pins nothing.
321
+ const presetRows = presetChoiceRows(options.presets)
238
322
  const inheritLabel = inheritPresetLabel(options.presets)
239
323
  const selectedPreset = options.presets.find(preset => preset.id === mode)
240
324
  const modeLabel = mode === '' ? inheritLabel : selectedPreset === undefined ? mode : presetLabel(selectedPreset)
@@ -249,6 +333,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
249
333
  const modelLabel = model === ''
250
334
  ? t('exec.model.default')
251
335
  : (options.models ?? []).find(item => item.id === model)?.name ?? model
336
+ // The effort is part of the model pin, so a pinned one rides the model's own
337
+ // summary entry instead of standing as a region-level value of its own.
338
+ const modelSummary = reasoningEffort === '' ? modelLabel : `${modelLabel} · ${reasoningEffort}`
252
339
  // Collapsed-region summaries: what each closed region currently holds, so a
253
340
  // collapse never hides the configuration without a trace.
254
341
  // A host-default model adds only the long parenthetical name to the
@@ -257,12 +344,14 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
257
344
  workspaceLabel,
258
345
  modeLabel,
259
346
  permissionLabel,
260
- ...(model === '' ? [] : [modelLabel]),
347
+ ...(model === '' ? [] : [modelSummary]),
261
348
  ].join(' · ')
262
349
  const tagCount = cleanTags(tags).length
263
350
  const labelsSummary = tagCount === 0 ? t('new.summary.none') : t('new.summary.labelCount', { count: String(tagCount) })
264
351
  const runSummary = [
265
- goalRun ? t('new.summary.multiRound') : t('new.summary.singleRound'),
352
+ // A card whose goal option is on but whose master switch is off will run one
353
+ // plain turn: the collapsed line says the truth rather than the stored value.
354
+ goalRun && goalRunEnabled ? t('new.summary.multiRound') : t('new.summary.singleRound'),
266
355
  ...(reuseSession ? [t('exec.reuseSession')] : []),
267
356
  ].join(' · ')
268
357
  const handoverSummary = freezeText.trim() !== '' || handoverText.trim() !== ''
@@ -270,7 +359,18 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
270
359
  : t('new.summary.none')
271
360
  const scheduleSummary = !scheduleEnabled
272
361
  ? t('new.summary.scheduleOff')
273
- : scheduleCron.trim() === '' ? t('new.summary.none') : scheduleCron.trim()
362
+ : scheduleMode === 'once'
363
+ ? `${t('detail.schedule.mode.once')} · ${scheduleAt.replace('T', ' ')}`
364
+ : scheduleCron.trim() === ''
365
+ ? t('new.summary.none')
366
+ : [
367
+ scheduleCron.trim(),
368
+ ...(scheduleMaxRunsChoice === 'unlimited'
369
+ ? []
370
+ : [t('detail.schedule.maxRunsValue', {
371
+ count: scheduleMaxRunsChoice === 'custom' ? scheduleMaxRunsText : scheduleMaxRunsChoice,
372
+ })]),
373
+ ].join(' · ')
274
374
  const parseSummary = parseText.trim() === '' ? t('new.summary.none') : t('new.summary.filled')
275
375
 
276
376
  return (
@@ -313,7 +413,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
313
413
  }}
314
414
  >
315
415
  <option value="">{t('exec.model.default')}</option>
316
- {parseModels.map(option => (
416
+ {models.map(option => (
317
417
  <option key={option.id} value={option.id}>{option.name ?? option.id}</option>
318
418
  ))}
319
419
  </select>
@@ -340,9 +440,14 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
340
440
  )}
341
441
 
342
442
  {parentTask !== undefined && (
343
- <p className={css.detailText} data-dsh-part="subtask-inherit">
344
- {t('detail.parent')}: {parentTask.title} · {t('new.subtaskInherit')}
345
- </p>
443
+ <>
444
+ <p className={css.detailText} data-dsh-part="subtask-inherit">
445
+ {t('detail.parent')}: {parentTask.title} · {t('new.subtaskInherit')}
446
+ </p>
447
+ {parentAwaitsTeamRunPromotion(controller, parentTask) && (
448
+ <p className={css.detailText}>{t('exec.teamRunAutoHint')}</p>
449
+ )}
450
+ </>
346
451
  )}
347
452
 
348
453
  <CollapsibleSection title={t('new.section.content')} defaultOpen>
@@ -395,25 +500,17 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
395
500
  >
396
501
  <option value="">{inheritLabel}</option>
397
502
  {!modeKnown && <option value={mode}>{mode}{t('exec.mode.removed')}</option>}
398
- {builtinPresets.length > 0 && (
503
+ {presetRows.builtin.length > 0 && (
399
504
  <optgroup label={t('exec.mode.builtinGroup')}>
400
- {builtinPresets.map(preset => (
401
- <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
402
- {presetLabel(preset)}
403
- {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
404
- {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
405
- </option>
505
+ {presetRows.builtin.map(row => (
506
+ <option key={row.id} value={row.id} disabled={row.disabled}>{row.label}</option>
406
507
  ))}
407
508
  </optgroup>
408
509
  )}
409
- {customPresets.length > 0 && (
510
+ {presetRows.custom.length > 0 && (
410
511
  <optgroup label={t('exec.mode.customGroup')}>
411
- {customPresets.map(preset => (
412
- <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
413
- {presetLabel(preset)}
414
- {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
415
- {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
416
- </option>
512
+ {presetRows.custom.map(row => (
513
+ <option key={row.id} value={row.id} disabled={row.disabled}>{row.label}</option>
417
514
  ))}
418
515
  </optgroup>
419
516
  )}
@@ -441,15 +538,29 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
441
538
  <select
442
539
  className={css.select}
443
540
  value={model}
444
- onChange={event => { setModel(event.target.value) }}
541
+ onChange={event => {
542
+ // The effort belongs to the model: switching to a model that
543
+ // cannot honor the picked level drops it instead of carrying it.
544
+ const next = event.target.value
545
+ setModel(next)
546
+ setReasoningEffort(current => reconcileReasoningEffort(models, next, current))
547
+ }}
445
548
  >
446
549
  <option value="">{t('exec.model.default')}</option>
447
550
  {!modelKnown && <option value={model}>{model}{t('exec.model.unknown')}</option>}
448
- {options.models?.map(item => (
551
+ {models.map(item => (
449
552
  <option key={item.id} value={item.id}>{item.name ?? item.id}</option>
450
553
  ))}
451
554
  </select>
452
555
  </label>
556
+
557
+ <ReasoningEffortField
558
+ models={models}
559
+ modelId={model}
560
+ value={reasoningEffort}
561
+ pending={pending}
562
+ onChange={setReasoningEffort}
563
+ />
453
564
  </CollapsibleSection>
454
565
 
455
566
  <CollapsibleSection title={t('new.section.run')} summary={runSummary}>
@@ -467,11 +578,15 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
467
578
  <input
468
579
  type="checkbox"
469
580
  checked={goalRun}
581
+ // Always operable: the global switch decides whether the choice
582
+ // takes effect, never whether it may be stated, and the state (and a
583
+ // duplicate's template) is never rewritten by it.
470
584
  onChange={event => { setGoalRun(event.target.checked) }}
471
585
  />
472
586
  <span>{t('exec.goalRun')}</span>
473
587
  </label>
474
588
  <p className={css.detailText}>{t('exec.goalRunHint')}</p>
589
+ {!goalRunEnabled && <p className={css.detailText}>{t('settings.goalRunGlobalDisabledTaskOption')}</p>}
475
590
 
476
591
  <label className={css.scheduleToggle}>
477
592
  <input
@@ -533,6 +648,32 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
533
648
  </label>
534
649
  {scheduleEnabled && (
535
650
  <>
651
+ <label className={css.scheduleZone}>
652
+ <span>{t('detail.schedule.mode')}</span>
653
+ <select
654
+ className={css.schedulePreset}
655
+ value={scheduleMode}
656
+ aria-label={t('detail.schedule.mode')}
657
+ onChange={event => { setScheduleMode(event.target.value as ScheduleMode); setScheduleError(undefined) }}
658
+ >
659
+ <option value="cron">{t('detail.schedule.mode.cron')}</option>
660
+ <option value="once">{t('detail.schedule.mode.once')}</option>
661
+ </select>
662
+ </label>
663
+ {scheduleMode === 'once' ? (
664
+ <label className={css.scheduleZone}>
665
+ <span>{t('detail.schedule.at')}</span>
666
+ <input
667
+ className={css.input}
668
+ type="datetime-local"
669
+ value={scheduleAt}
670
+ aria-label={t('detail.schedule.at')}
671
+ title={t('detail.schedule.atHint')}
672
+ onChange={event => { setScheduleAt(event.target.value); setScheduleError(undefined) }}
673
+ />
674
+ </label>
675
+ ) : (
676
+ <>
536
677
  <div className={css.scheduleRow}>
537
678
  <input
538
679
  className={`${css.input} ${css.scheduleInput}${scheduleError !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
@@ -558,6 +699,42 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
558
699
  ))}
559
700
  </select>
560
701
  </div>
702
+ <label className={css.scheduleZone}>
703
+ <span>{t('detail.schedule.maxRuns')}</span>
704
+ <select
705
+ className={css.schedulePreset}
706
+ value={scheduleMaxRunsChoice}
707
+ aria-label={t('detail.schedule.maxRuns')}
708
+ onChange={event => {
709
+ setScheduleMaxRunsChoice(event.target.value)
710
+ setScheduleError(undefined)
711
+ if (event.target.value === 'custom') {
712
+ setScheduleMaxRunsText(initialTask?.schedule?.maxRuns === undefined ? '' : String(initialTask.schedule.maxRuns))
713
+ }
714
+ }}
715
+ >
716
+ <option value="unlimited">{t('detail.schedule.maxRunsUnlimited')}</option>
717
+ <option value="1">{t('detail.schedule.maxRunsValue', { count: '1' })}</option>
718
+ <option value="2">{t('detail.schedule.maxRunsValue', { count: '2' })}</option>
719
+ <option value="custom">{t('detail.schedule.maxRunsCustom')}</option>
720
+ </select>
721
+ </label>
722
+ {scheduleMaxRunsChoice === 'custom' && (
723
+ <label className={css.scheduleZone}>
724
+ <span>{t('detail.schedule.maxRuns')}</span>
725
+ <input
726
+ className={css.input}
727
+ type="number"
728
+ min={1}
729
+ step={1}
730
+ value={scheduleMaxRunsText}
731
+ aria-label={t('detail.schedule.maxRuns')}
732
+ onChange={event => { setScheduleMaxRunsText(event.target.value); setScheduleError(undefined) }}
733
+ />
734
+ </label>
735
+ )}
736
+ </>
737
+ )}
561
738
  {scheduleError !== undefined && <p className={css.formError}>{scheduleError}</p>}
562
739
  <label className={css.scheduleZone}>
563
740
  <span>{t('detail.schedule.timeZone')}</span>
@@ -565,7 +742,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
565
742
  className={css.schedulePreset}
566
743
  value={scheduleZone}
567
744
  aria-label={t('detail.schedule.timeZone')}
568
- title={t('detail.schedule.timeZoneHint')}
745
+ title={scheduleMode === 'once' ? t('detail.schedule.zoneOnceHint') : t('detail.schedule.timeZoneHint')}
569
746
  onChange={event => { setScheduleZone(event.target.value); setScheduleError(undefined) }}
570
747
  >
571
748
  {zoneChoices(hostTimeZone, initialTask?.schedule?.timeZone).map(choice => (
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Reasoning-effort field: one picker shared by both execution-target editors
3
+ * (the create dialog and the detail page), so the two never describe the same
4
+ * capability differently. The levels are the ones the picked model's catalog
5
+ * entry declares; an empty value is a real choice — "follow the model or Host
6
+ * default" — and is the default state.
7
+ *
8
+ * A stored level the roster no longer lists (the model left the roster, or its
9
+ * adapter changed) stays shown and labelled, but is not offered again, so the
10
+ * field never silently shows a different value than the one the card will ask
11
+ * for while the choosable set stays exactly the levels the model declares.
12
+ */
13
+ import type { ExecutionModelOption } from '../../core/controller.ts'
14
+ import { t } from '../locales.ts'
15
+ import css from '../board.module.css'
16
+ import { reasoningEffortsFor, reasoningHintKey, reasoningPickerEnabled } from './model-options.ts'
17
+
18
+ /** The controlled reasoning-effort picker. */
19
+ export function ReasoningEffortField({ models, modelId, value, pending, onChange }: {
20
+ /** The roster the picked model is looked up in. */
21
+ models: readonly ExecutionModelOption[]
22
+ /** The model this card pins; '' means "no pin". */
23
+ modelId: string
24
+ /** The selected effort; '' means "follow the model or Host default". */
25
+ value: string
26
+ /** Whether a mutation is in flight (the field locks with the rest of the form). */
27
+ pending: boolean
28
+ onChange: (next: string) => void
29
+ }) {
30
+ const efforts = reasoningEffortsFor(models, modelId)
31
+ const unlisted = value !== '' && !efforts.includes(value)
32
+ return (
33
+ <>
34
+ <label className={css.field}>
35
+ <span className={css.fieldLabel}>{t('exec.reasoning')}</span>
36
+ <select
37
+ className={css.select}
38
+ value={value}
39
+ disabled={pending || !reasoningPickerEnabled(models, modelId)}
40
+ onChange={event => { onChange(event.target.value) }}
41
+ >
42
+ <option value="">{t('exec.reasoning.inherit')}</option>
43
+ {unlisted && <option value={value} disabled>{value}{t('exec.reasoning.unknown')}</option>}
44
+ {efforts.map(effort => <option key={effort} value={effort}>{effort}</option>)}
45
+ </select>
46
+ </label>
47
+ <p className={css.detailText}>{t(reasoningHintKey(models, modelId))}</p>
48
+ </>
49
+ )
50
+ }
@@ -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 && (